@tellescope/sdk 1.253.0 → 1.253.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/lib/cjs/tests/api_tests/beluga_manual_sync.test.d.ts +6 -0
  2. package/lib/cjs/tests/api_tests/beluga_manual_sync.test.d.ts.map +1 -0
  3. package/lib/cjs/tests/api_tests/beluga_manual_sync.test.js +256 -0
  4. package/lib/cjs/tests/api_tests/beluga_manual_sync.test.js.map +1 -0
  5. package/lib/cjs/tests/api_tests/gcal_sync_retry.test.d.ts +43 -0
  6. package/lib/cjs/tests/api_tests/gcal_sync_retry.test.d.ts.map +1 -0
  7. package/lib/cjs/tests/api_tests/gcal_sync_retry.test.js +168 -0
  8. package/lib/cjs/tests/api_tests/gcal_sync_retry.test.js.map +1 -0
  9. package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts +23 -0
  10. package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -0
  11. package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +325 -0
  12. package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -0
  13. package/lib/cjs/tests/api_tests/user_portal_settings.test.d.ts.map +1 -1
  14. package/lib/cjs/tests/api_tests/user_portal_settings.test.js +104 -28
  15. package/lib/cjs/tests/api_tests/user_portal_settings.test.js.map +1 -1
  16. package/lib/cjs/tests/tests.d.ts.map +1 -1
  17. package/lib/cjs/tests/tests.js +444 -174
  18. package/lib/cjs/tests/tests.js.map +1 -1
  19. package/lib/esm/tests/api_tests/beluga_manual_sync.test.d.ts +6 -0
  20. package/lib/esm/tests/api_tests/beluga_manual_sync.test.d.ts.map +1 -0
  21. package/lib/esm/tests/api_tests/beluga_manual_sync.test.js +252 -0
  22. package/lib/esm/tests/api_tests/beluga_manual_sync.test.js.map +1 -0
  23. package/lib/esm/tests/api_tests/gcal_sync_retry.test.d.ts +43 -0
  24. package/lib/esm/tests/api_tests/gcal_sync_retry.test.d.ts.map +1 -0
  25. package/lib/esm/tests/api_tests/gcal_sync_retry.test.js +164 -0
  26. package/lib/esm/tests/api_tests/gcal_sync_retry.test.js.map +1 -0
  27. package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts +23 -0
  28. package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -0
  29. package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +321 -0
  30. package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -0
  31. package/lib/esm/tests/api_tests/user_portal_settings.test.d.ts.map +1 -1
  32. package/lib/esm/tests/api_tests/user_portal_settings.test.js +104 -28
  33. package/lib/esm/tests/api_tests/user_portal_settings.test.js.map +1 -1
  34. package/lib/esm/tests/tests.d.ts.map +1 -1
  35. package/lib/esm/tests/tests.js +444 -174
  36. package/lib/esm/tests/tests.js.map +1 -1
  37. package/lib/tsconfig.tsbuildinfo +1 -1
  38. package/package.json +10 -10
  39. package/src/tests/api_tests/beluga_manual_sync.test.ts +159 -0
  40. package/src/tests/api_tests/gcal_sync_retry.test.ts +104 -0
  41. package/src/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.ts +214 -0
  42. package/src/tests/api_tests/user_portal_settings.test.ts +71 -1
  43. package/src/tests/tests.ts +222 -6
  44. package/test_generated.pdf +0 -0
@@ -0,0 +1,159 @@
1
+ require('source-map-support').install();
2
+
3
+ import { Session } from "../../sdk"
4
+ import {
5
+ async_test,
6
+ log_header,
7
+ } from "@tellescope/testing"
8
+ import { setup_tests } from "../setup"
9
+ import { BELUGA_TITLE } from "@tellescope/constants"
10
+
11
+ const host = process.env.API_URL || 'http://localhost:8080' as const
12
+
13
+ // Manual Beluga re-sync guard tests (CU-86e1uxz1n).
14
+ // - form_responses.push_to_EHR with target === BELUGA_TITLE
15
+ // - files.push with destination === BELUGA_TITLE
16
+ //
17
+ // Fast / no-upload: this only verifies the bad-input / guard branches that reject before any
18
+ // Beluga call. It performs NO S3 file uploads and triggers NO actual sync (both slow and require
19
+ // live Beluga sandbox credentials). File records are created via prepare_file_upload alone — that
20
+ // inserts the DB record, which is all the guards need.
21
+ export const beluga_manual_sync_tests = async ({ sdk, sdkNonAdmin }: { sdk: Session, sdkNonAdmin: Session }) => {
22
+ log_header("Beluga Manual Sync Guard Tests (FormResponses & Files)")
23
+
24
+ const errorMessage = (e: any) => (e?.message || e?.toString?.() || JSON.stringify(e)) as string
25
+
26
+ let enduserId: string | undefined
27
+ let belugaFormId: string | undefined
28
+ let plainFormId: string | undefined
29
+ const fileIds: string[] = []
30
+ let createdBelugaIntegrationId: string | undefined
31
+
32
+ try {
33
+ const enduser = await sdk.api.endusers.createOne({
34
+ fname: 'beluga-sync',
35
+ email: `beluga_manual_sync_${Date.now()}@test.tellescope.com`,
36
+ })
37
+ enduserId = enduser.id
38
+
39
+ // Beluga-configured form (belugaVisitType set) and a plain (non-Beluga) form
40
+ const belugaForm = await sdk.api.forms.createOne({ title: 'Beluga Manual Sync Form', belugaVisitType: 'sync' })
41
+ belugaFormId = belugaForm.id
42
+
43
+ const plainForm = await sdk.api.forms.createOne({ title: 'Non-Beluga Manual Sync Form' })
44
+ plainFormId = plainForm.id
45
+ // A form needs at least one field + a matching answer to be submittable (empty responses are rejected)
46
+ const plainField = await sdk.api.form_fields.createOne({
47
+ formId: plainForm.id, type: 'string', title: 'Field', previousFields: [{ type: 'root', info: {} }],
48
+ })
49
+
50
+ const submitForm = async (formId: string) => {
51
+ const { accessCode, response } = await sdk.api.form_responses.prepare_form_response({ enduserId: enduser.id, formId })
52
+ await sdk.api.form_responses.submit_form_response({
53
+ accessCode,
54
+ responses: [{ fieldId: plainField.id, fieldTitle: 'Field', answer: { type: 'string', value: 'x' } }],
55
+ })
56
+ return response.id
57
+ }
58
+
59
+ // Create a File DB record WITHOUT uploading to S3. prepare_file_upload inserts the record and
60
+ // returns it; skipping sdk.UPLOAD / confirm_file_upload keeps the test fast. The guards only
61
+ // read fields off the record (formResponseId, references), so no real upload is needed.
62
+ const createFileRecord = async (name: string) => {
63
+ const { file } = await sdk.api.files.prepare_file_upload({
64
+ name, type: 'text/plain', size: 1, enduserId: enduser.id,
65
+ })
66
+ fileIds.push(file.id)
67
+ return file
68
+ }
69
+
70
+ // ──────────────────────────────────────────────────────────────────────────
71
+ // 1. FormResponse guards (integration-independent)
72
+ // ──────────────────────────────────────────────────────────────────────────
73
+
74
+ // Unsubmitted draft → rejected before any integration lookup
75
+ const { response: draft } = await sdk.api.form_responses.prepare_form_response({ enduserId: enduser.id, formId: belugaForm.id })
76
+ await async_test(
77
+ "push_to_EHR(target=BELUGA) rejects an unsubmitted form response",
78
+ () => sdk.api.form_responses.push_to_EHR({ id: draft.id, target: BELUGA_TITLE }),
79
+ { shouldError: true, onError: (e: any) => /has not been submitted/i.test(errorMessage(e)) }
80
+ )
81
+
82
+ // Submitted, but the form has no belugaVisitType → rejected as not configured
83
+ const plainResponseId = await submitForm(plainForm.id)
84
+ await async_test(
85
+ "push_to_EHR(target=BELUGA) rejects a form not configured for Beluga",
86
+ () => sdk.api.form_responses.push_to_EHR({ id: plainResponseId, target: BELUGA_TITLE }),
87
+ { shouldError: true, onError: (e: any) => /not configured for Beluga/i.test(errorMessage(e)) }
88
+ )
89
+
90
+ // ──────────────────────────────────────────────────────────────────────────
91
+ // 2. files.push(destination=BELUGA) guard — the endpoint resolves the destination
92
+ // integration first, so a Beluga integration must exist for the branch to be reached.
93
+ // Create a placeholder if the org has none; skip gracefully if that's not permitted.
94
+ // ──────────────────────────────────────────────────────────────────────────
95
+
96
+ let belugaIntegrationAvailable = false
97
+ try {
98
+ const existing = await sdk.api.integrations.load_redacted({})
99
+ belugaIntegrationAvailable = !!existing.integrations.find((i: any) => i.title === BELUGA_TITLE)
100
+ } catch { /* load not permitted — fall through to create attempt */ }
101
+
102
+ if (!belugaIntegrationAvailable) {
103
+ try {
104
+ const created = await sdk.api.integrations.createOne({
105
+ title: BELUGA_TITLE,
106
+ authentication: {
107
+ type: 'oauth2',
108
+ info: { access_token: 'test-access-token', refresh_token: 'test-refresh-token', scope: '', token_type: 'Bearer', expiry_date: new Date().getTime() },
109
+ },
110
+ })
111
+ createdBelugaIntegrationId = created.id
112
+ belugaIntegrationAvailable = true
113
+ } catch (e) {
114
+ console.log("Could not create a Beluga integration for testing; skipping files.push guard:", errorMessage(e))
115
+ }
116
+ }
117
+
118
+ if (belugaIntegrationAvailable) {
119
+ // A file with no associated formResponseId cannot be synced to Beluga
120
+ const unlinkedFile = await createFileRecord('beluga-unlinked.txt')
121
+ await async_test(
122
+ "files.push(destination=BELUGA) rejects a file with no associated form response",
123
+ () => sdk.api.files.push({ id: unlinkedFile.id, destination: BELUGA_TITLE }),
124
+ { shouldError: true, onError: (e: any) => /not associated with a form response/i.test(errorMessage(e)) }
125
+ )
126
+ } else {
127
+ console.log("⏭️ Skipping files.push(destination=BELUGA) guard (no Beluga integration available)")
128
+ }
129
+ } finally {
130
+ for (const id of fileIds) {
131
+ await sdk.api.files.deleteOne(id).catch(console.error)
132
+ }
133
+ if (belugaFormId) await sdk.api.forms.deleteOne(belugaFormId).catch(console.error)
134
+ if (plainFormId) await sdk.api.forms.deleteOne(plainFormId).catch(console.error)
135
+ if (enduserId) await sdk.api.endusers.deleteOne(enduserId).catch(console.error)
136
+ if (createdBelugaIntegrationId) await sdk.api.integrations.deleteOne(createdBelugaIntegrationId).catch(console.error)
137
+ }
138
+ }
139
+
140
+ if (require.main === module) {
141
+ console.log(`Using API URL: ${host}`)
142
+ const sdk = new Session({ host })
143
+ const sdkNonAdmin = new Session({ host })
144
+
145
+ const runTests = async () => {
146
+ await setup_tests(sdk, sdkNonAdmin)
147
+ await beluga_manual_sync_tests({ sdk, sdkNonAdmin })
148
+ }
149
+
150
+ runTests()
151
+ .then(() => {
152
+ console.log("✅ Beluga manual sync test suite completed successfully")
153
+ process.exit(0)
154
+ })
155
+ .catch((error) => {
156
+ console.error("❌ Beluga manual sync test suite failed:", error)
157
+ process.exit(1)
158
+ })
159
+ }
@@ -0,0 +1,104 @@
1
+ require('source-map-support').install();
2
+
3
+ import { Session } from "../../sdk"
4
+ import {
5
+ async_test,
6
+ log_header,
7
+ wait,
8
+ } from "@tellescope/testing"
9
+ import { setup_tests } from "../setup"
10
+
11
+ const host = process.env.API_URL || 'http://localhost:8080' as const
12
+
13
+ /**
14
+ * Google Calendar sync retry — integration coverage.
15
+ *
16
+ * ┌─ ARCHITECTURE NOTE (why the full mock-Google scenarios are gated) ──────────┐
17
+ * │ SDK api_tests run in a SEPARATE process from the API server (they talk to │
18
+ * │ host = API_URL / http://localhost:8080). The retry scheduler, the Google │
19
+ * │ retryable-error predicates, and the google.calendar() client all live in the │
20
+ * │ API SERVER process. A stub installed here (in the SDK test process) cannot │
21
+ * │ replace the server's in-process google client, so we cannot deterministically│
22
+ * │ inject 429 / 500 / ECONNRESET responses from this test alone. │
23
+ * │ │
24
+ * │ Two ways to run the full fail-429-then-succeed / exhaustion / cap scenarios: │
25
+ * │ 1. Run the API server with NODE_ENV=test and RETRY_SCHEDULER_DELAYS_MS=10,20 │
26
+ * │ (already honored by the singleton constructor) plus a server-side │
27
+ * │ fault-injection hook on sdk.events.* that reads e.g. │
28
+ * │ process.env.GCAL_TEST_FORCE_ERROR — that hook does not exist yet. │
29
+ * │ 2. Drive the scheduler directly with the in-process unit tests, which DO │
30
+ * │ cover all retry mechanics deterministically: │
31
+ * │ packages/private/api/api/modules/retry_scheduler.test.ts │
32
+ * │ packages/private/api/api/integrations/google.test.ts │
33
+ * │ │
34
+ * │ The scenario matrix below is recorded for when a server-side hook is added. │
35
+ * └──────────────────────────────────────────────────────────────────────────────┘
36
+ *
37
+ * Scenario matrix (requires server-side Google fault injection — see note):
38
+ * 1. create retry: fail 429 once then succeed -> gcal reference written, NO background_errors
39
+ * 2. exhaustion: fail 429 on every call -> exactly one "Google Calendar Push Error" background_errors row
40
+ * 3. non-retryable: fail 403 -> background_errors written immediately, no retries
41
+ * 4. create idempotency: fail ECONNRESET (network) -> NON-retryable for create (duplicate risk),
42
+ * background_errors written, no retry, no duplicate insert
43
+ * 5. update + delete: same as (1) for events.patch and events.delete
44
+ * 6. cap exceeded: maxOpenRetries=2, 3 events all fail retryably -> 3rd -> background_errors immediately
45
+ *
46
+ * The runnable assertions below cover what IS observable end-to-end without a
47
+ * connected Google account: the refactored call sites must not regress the common
48
+ * "user has no Google integration" path (no sync attempt, no background_errors, no retry).
49
+ */
50
+ export const gcal_sync_retry_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
51
+ log_header("Google Calendar Sync Retry")
52
+
53
+ if (process.env.GCAL_RETRY_INTEGRATION !== '1') {
54
+ console.log("ℹ️ Skipping mock-Google scenarios (set GCAL_RETRY_INTEGRATION=1 with a server-side "
55
+ + "fault-injection hook to run scenarios 1-6). Running no-integration regression checks only.")
56
+ }
57
+
58
+ // No-integration regression check: a calendar event with a user attendee whose
59
+ // account has no Google integration should sync-skip silently (no background_errors).
60
+ const enduser = await sdk.api.endusers.createOne({ fname: 'GcalRetry', lname: 'Test' })
61
+
62
+ await async_test(
63
+ 'create event for user without Google integration does not produce a background error',
64
+ async () => {
65
+ const event = await sdk.api.calendar_events.createOne({
66
+ title: 'Gcal Retry Regression',
67
+ startTimeInMS: Date.now() + 1000 * 60 * 60,
68
+ durationInMinutes: 30,
69
+ attendees: [{ type: 'user', id: sdk.userInfo.id }, { type: 'enduser', id: enduser.id }],
70
+ })
71
+
72
+ // give side-effect handlers a moment to run
73
+ await wait(undefined, 750)
74
+
75
+ const errors = await sdk.api.background_errors.getSome({}).catch(() => [])
76
+
77
+ // clean up
78
+ await sdk.api.calendar_events.deleteOne(event.id).catch(() => {})
79
+
80
+ // No Google integration on the test user => no push attempt => no error row.
81
+ return errors.filter((e: any) => (
82
+ e.userId === sdk.userInfo.id && e.title === "Google Calendar Push Error"
83
+ )).length
84
+ },
85
+ { onResult: (count) => count === 0 },
86
+ )
87
+
88
+ // cleanup
89
+ await sdk.api.endusers.deleteOne(enduser.id).catch(() => {})
90
+ }
91
+
92
+ if (require.main === module) {
93
+ const sdk = new Session({ host })
94
+ const sdkNonAdmin = new Session({ host })
95
+
96
+ const runTests = async () => {
97
+ await setup_tests(sdk, sdkNonAdmin)
98
+ await gcal_sync_retry_tests({ sdk, sdkNonAdmin })
99
+ }
100
+
101
+ runTests()
102
+ .then(() => { console.log("✅ gcal sync retry test suite completed successfully"); process.exit(0) })
103
+ .catch((error) => { console.error("❌ gcal sync retry test suite failed:", error); process.exit(1) })
104
+ }
@@ -0,0 +1,214 @@
1
+ require('source-map-support').install();
2
+
3
+ import { Session, EnduserSession } from "../../../sdk"
4
+ import {
5
+ async_test,
6
+ log_header,
7
+ } from "@tellescope/testing"
8
+ import { setup_tests } from "../../setup"
9
+
10
+ const host = process.env.API_URL || 'http://localhost:8080' as const
11
+ const businessId = '60398b1131a295e64f084ff6'
12
+
13
+ const ENDUSER_PASSWORD = 'F0106TestPassword!123'
14
+
15
+ // Every endusers field marked enduserUpdatesDisabled in schema.ts, with a
16
+ // validator-passing sample value so the only rejection in play is the
17
+ // write-restriction (not input validation).
18
+ const restrictedEnduserUpdates = (staffId: string, mongoId: string): Record<string, any> => ({
19
+ externalId: 'f0106-attacker-external-id',
20
+ tags: ['f0106-attacker-tag'],
21
+ accessTags: ['f0106-attacker-access-tag'],
22
+ assignedTo: [staffId],
23
+ customTypeId: mongoId,
24
+ references: [{ type: 'Vital', id: 'f0106-attacker-reference' }],
25
+ sharedWithOrganizations: [],
26
+ journeys: { [mongoId]: 'Added' },
27
+ primaryAssignee: staffId,
28
+ fields: { f0106AttackerField: 'true' },
29
+ unread: true,
30
+ source: 'f0106-attacker-source',
31
+ note: 'f0106 attacker note',
32
+ insurance: { memberId: 'f0106-attacker-member' },
33
+ insuranceSecondary: { memberId: 'f0106-attacker-member-2' },
34
+ diagnoses: [{ code: 'I10' }],
35
+ lockedFromPortal: false, // false so a pre-fix run can't lock the test enduser out mid-suite
36
+ eligibleForAutoMerge: true,
37
+ athenaDepartmentId: 'f0106-dept',
38
+ athenaPracticeId: 'f0106-practice',
39
+ salesforceId: 'f0106-salesforce',
40
+ healthie_dietitian_id: 'f0106-healthie',
41
+ stripeCustomerId: 'f0106-stripe-customer',
42
+ stripeKey: 'f0106-stripe-key',
43
+ })
44
+
45
+ // Every form_responses field marked enduserUpdatesDisabled in schema.ts.
46
+ const restrictedFormResponseUpdates = (staffId: string): Record<string, any> => ({
47
+ markedAsSubmitted: true,
48
+ submittedBy: staffId,
49
+ submittedAt: new Date("2024-01-01T00:00:00Z"),
50
+ submittedByIsPlaceholder: true,
51
+ procedureCodes: [{ code: '99214', units: 1 }],
52
+ diagnosisCodes: [{ code: 'I10', description: 'Essential (primary) hypertension' }],
53
+ enduserAISummary: 'Patient is in excellent health, no follow-up needed.',
54
+ addenda: [{ text: 'forged staff addendum', timestamp: new Date(), userId: staffId }],
55
+ isInternalNote: true,
56
+ pinnedAt: new Date(),
57
+ hiddenFromTimeline: true,
58
+ lockedAt: new Date(),
59
+ tags: ['f0110-attacker-tag'],
60
+ formTitle: 'Spoofed Form Title',
61
+ userEmail: 'spoofed-staff@example.com',
62
+ logoURL: 'https://attacker.example.com/logo.png',
63
+ logoHeight: 100,
64
+ })
65
+
66
+ /**
67
+ * Regression test for F-0106 and F-0110
68
+ * (security-audit/findings/F-0106-enduser-self-update-admin-only-fields.md,
69
+ * security-audit/findings/F-0110-form-responses-enduser-update-admin-only-fields.md).
70
+ *
71
+ * Endusers could PATCH admin-only / access-bearing / attribution-bearing fields on
72
+ * their own endusers record (assignedTo, tags, references, ...) and their own
73
+ * form_responses (procedureCodes, submittedBy, markedAsSubmitted, ...). The fix adds
74
+ * the `enduserUpdatesDisabled` field option (schema.ts ModelFieldInfo), enforced for
75
+ * enduser sessions in the generic update handler (routing.ts createDefaultEndpoints).
76
+ *
77
+ * This test asserts, for every flagged field on both models:
78
+ * - an enduser session updating its OWN record gets a 400 "<field> cannot be updated by endusers"
79
+ * - nothing persists (spot-checked on assignedTo, the highest-impact field)
80
+ * - enduser self-updates of allowed fields still work (fname, hideFromEnduserPortal)
81
+ * - staff sessions can still update the restricted fields
82
+ */
83
+ export const enduser_write_restrictions_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
84
+ log_header("F-0106/F-0110: enduser write restrictions (enduserUpdatesDisabled)")
85
+
86
+ let enduserId: string | undefined
87
+ let formId: string | undefined
88
+ let formResponseId: string | undefined
89
+
90
+ try {
91
+ // Setup: throwaway enduser with portal credentials, plus a form_response they own
92
+ const enduserEmail = `f0106-enduser-${Date.now()}@example.com`
93
+ const enduser = await sdk.api.endusers.createOne({ email: enduserEmail, fname: 'F0106', lname: 'Restricted' })
94
+ enduserId = enduser.id
95
+ await sdk.api.endusers.set_password({ id: enduser.id, password: ENDUSER_PASSWORD })
96
+
97
+ const enduserSDK = new EnduserSession({ host, businessId })
98
+ await enduserSDK.authenticate(enduserEmail, ENDUSER_PASSWORD)
99
+
100
+ const form = await sdk.api.forms.createOne({ title: 'F-0106/F-0110 Write Restriction Test Form' })
101
+ formId = form.id
102
+ const { response: formResponse } = await sdk.api.form_responses.prepare_form_response({
103
+ formId: form.id,
104
+ enduserId: enduser.id,
105
+ })
106
+ formResponseId = formResponse.id
107
+
108
+ // ---- F-0106: enduser self-update of restricted endusers fields must 400 ----
109
+ for (const [field, value] of Object.entries(restrictedEnduserUpdates(sdk.userInfo.id, enduser.id))) {
110
+ await async_test(
111
+ `F-0106: enduser cannot self-update endusers.${field}`,
112
+ () => enduserSDK.api.endusers.updateOne(enduser.id, { [field]: value } as any),
113
+ { shouldError: true, onError: e => e.message === `${field} cannot be updated by endusers` },
114
+ )
115
+ }
116
+
117
+ // F-0106 Variant A persistence check: the rejected assignedTo write must not
118
+ // have granted the staff read-filter match.
119
+ await async_test(
120
+ 'F-0106: rejected assignedTo write did not persist',
121
+ () => sdk.api.endusers.getOne(enduser.id),
122
+ { onResult: e => !e.assignedTo?.length && !e.tags?.length && e.externalId === undefined },
123
+ )
124
+
125
+ // ---- F-0110: enduser self-update of restricted form_responses fields must 400 ----
126
+ for (const [field, value] of Object.entries(restrictedFormResponseUpdates(sdk.userInfo.id))) {
127
+ await async_test(
128
+ `F-0110: enduser cannot self-update form_responses.${field}`,
129
+ () => enduserSDK.api.form_responses.updateOne(formResponse.id, { [field]: value } as any),
130
+ { shouldError: true, onError: e => e.message === `${field} cannot be updated by endusers` },
131
+ )
132
+ }
133
+
134
+ await async_test(
135
+ 'F-0110: rejected writes did not persist (markedAsSubmitted, procedureCodes, submittedBy)',
136
+ () => sdk.api.form_responses.getOne(formResponse.id),
137
+ { onResult: fr => !fr.markedAsSubmitted && !fr.procedureCodes?.length && fr.submittedBy === undefined },
138
+ )
139
+
140
+ // ---- Positive controls: intended enduser self-updates still work ----
141
+ await async_test(
142
+ 'enduser can still update own profile fields (fname/lname)',
143
+ () => enduserSDK.api.endusers.updateOne(enduser.id, { fname: 'StillAllowed', lname: 'Patient' }),
144
+ { onResult: e => e.fname === 'StillAllowed' && e.lname === 'Patient' },
145
+ )
146
+ await async_test(
147
+ 'enduser can still update own form_response (hideFromEnduserPortal — intended per schema)',
148
+ () => enduserSDK.api.form_responses.updateOne(formResponse.id, { hideFromEnduserPortal: true }),
149
+ { onResult: fr => fr.hideFromEnduserPortal === true },
150
+ )
151
+
152
+ // ---- Positive controls: staff sessions are unaffected by enduserUpdatesDisabled ----
153
+ await async_test(
154
+ 'staff can still update restricted endusers fields (tags, assignedTo, externalId, note, source)',
155
+ () => sdk.api.endusers.updateOne(enduser.id, {
156
+ tags: ['staff-set-tag'],
157
+ assignedTo: [sdk.userInfo.id],
158
+ externalId: 'staff-set-external-id',
159
+ note: 'staff-set note',
160
+ source: 'staff-set-source',
161
+ }),
162
+ { onResult: e => (
163
+ !!e.tags?.includes('staff-set-tag')
164
+ && !!e.assignedTo?.includes(sdk.userInfo.id)
165
+ && e.externalId === 'staff-set-external-id'
166
+ ) },
167
+ )
168
+ await async_test(
169
+ 'staff can still update restricted form_responses fields (procedureCodes, diagnosisCodes, tags)',
170
+ () => sdk.api.form_responses.updateOne(formResponse.id, {
171
+ procedureCodes: [{ code: 'G0019', units: 1 }],
172
+ diagnosisCodes: [{ code: 'Z71.3', description: 'Dietary counseling and surveillance' }],
173
+ tags: ['staff-set-tag'],
174
+ }),
175
+ { onResult: fr => (
176
+ fr.procedureCodes?.[0]?.code === 'G0019'
177
+ && fr.diagnosisCodes?.[0]?.code === 'Z71.3'
178
+ && !!fr.tags?.includes('staff-set-tag')
179
+ ) },
180
+ )
181
+ } finally {
182
+ if (formResponseId) {
183
+ try { await sdk.api.form_responses.deleteOne(formResponseId) } catch {}
184
+ }
185
+ if (formId) {
186
+ try { await sdk.api.forms.deleteOne(formId) } catch {}
187
+ }
188
+ if (enduserId) {
189
+ try { await sdk.api.endusers.deleteOne(enduserId) } catch {}
190
+ }
191
+ }
192
+ }
193
+
194
+ // Allow running this test file independently
195
+ if (require.main === module) {
196
+ console.log(`🌐 Using API URL: ${host}`)
197
+ const sdk = new Session({ host })
198
+ const sdkNonAdmin = new Session({ host })
199
+
200
+ const runTests = async () => {
201
+ await setup_tests(sdk, sdkNonAdmin)
202
+ await enduser_write_restrictions_tests({ sdk, sdkNonAdmin })
203
+ }
204
+
205
+ runTests()
206
+ .then(() => {
207
+ console.log("✅ F-0106/F-0110 enduser write-restriction test suite completed successfully")
208
+ process.exit(0)
209
+ })
210
+ .catch((error) => {
211
+ console.error("❌ F-0106/F-0110 enduser write-restriction test suite failed:", error)
212
+ process.exit(1)
213
+ })
214
+ }
@@ -25,6 +25,11 @@ export const user_portal_settings_tests = async ({ sdk, sdkNonAdmin }: { sdk: Se
25
25
  let testEnduserId: string | undefined
26
26
  let enduserSDK: EnduserSession | undefined
27
27
 
28
+ // Organization.onboardingStatus shares userPortalSettingsValidator — save the
29
+ // current value so we can restore it after exercising the field below.
30
+ const orgId = sdk.userInfo.businessId
31
+ const originalOnboardingStatus = (await sdk.api.organizations.getOne(orgId)).onboardingStatus
32
+
28
33
  try {
29
34
  // ===== Valid: string values =====
30
35
  await async_test(
@@ -179,6 +184,63 @@ export const user_portal_settings_tests = async ({ sdk, sdkNonAdmin }: { sdk: Se
179
184
  }
180
185
  )
181
186
 
187
+ // ===== Organization.onboardingStatus: same key-value shape as portalSettings =====
188
+ await async_test(
189
+ 'onboardingStatus - mixed string and boolean values round-trip',
190
+ async () => {
191
+ await sdk.api.organizations.updateOne(
192
+ orgId,
193
+ { onboardingStatus: { completedIntro: true, currentStep: 'billing', skippedTour: false } },
194
+ { replaceObjectFields: true }
195
+ )
196
+ const updated = await sdk.api.organizations.getOne(orgId)
197
+ return updated.onboardingStatus
198
+ },
199
+ {
200
+ onResult: (r) =>
201
+ r?.completedIntro === true &&
202
+ typeof r?.completedIntro === 'boolean' &&
203
+ r?.currentStep === 'billing' &&
204
+ typeof r?.currentStep === 'string' &&
205
+ r?.skippedTour === false &&
206
+ typeof r?.skippedTour === 'boolean',
207
+ }
208
+ )
209
+
210
+ await async_test(
211
+ 'onboardingStatus - empty object accepted',
212
+ async () => {
213
+ await sdk.api.organizations.updateOne(orgId, { onboardingStatus: {} }, { replaceObjectFields: true })
214
+ const updated = await sdk.api.organizations.getOne(orgId)
215
+ return updated.onboardingStatus
216
+ },
217
+ { onResult: (r) => !!r && typeof r === 'object' && Object.keys(r).length === 0 }
218
+ )
219
+
220
+ // breaking change: the previous string shape must no longer be accepted
221
+ await async_test(
222
+ 'onboardingStatus - plain string (old shape) rejected',
223
+ () => sdk.api.organizations.updateOne(
224
+ orgId,
225
+ { onboardingStatus: 'started' as any },
226
+ { replaceObjectFields: true }
227
+ ),
228
+ handleAnyError
229
+ )
230
+
231
+ // representative validator rejection, confirming the indexable validator is
232
+ // enforced on the organizations route (full coverage lives in the
233
+ // portalSettings cases above, which use the same validator)
234
+ await async_test(
235
+ 'onboardingStatus - number value rejected',
236
+ () => sdk.api.organizations.updateOne(
237
+ orgId,
238
+ { onboardingStatus: { k: 1 as any } },
239
+ { replaceObjectFields: true }
240
+ ),
241
+ handleAnyError
242
+ )
243
+
182
244
  console.log("✅ All User portalSettings tests passed!")
183
245
  } finally {
184
246
  try {
@@ -189,7 +251,15 @@ export const user_portal_settings_tests = async ({ sdk, sdkNonAdmin }: { sdk: Se
189
251
  await sdk.api.endusers.deleteOne(testEnduserId)
190
252
  }
191
253
  } finally {
192
- await sdk.api.users.deleteOne(testUser.id)
254
+ try {
255
+ await sdk.api.organizations.updateOne(
256
+ orgId,
257
+ { onboardingStatus: originalOnboardingStatus ?? {} },
258
+ { replaceObjectFields: true }
259
+ )
260
+ } finally {
261
+ await sdk.api.users.deleteOne(testUser.id)
262
+ }
193
263
  }
194
264
  }
195
265
  }