@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.
- package/lib/cjs/tests/api_tests/beluga_manual_sync.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/beluga_manual_sync.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/beluga_manual_sync.test.js +256 -0
- package/lib/cjs/tests/api_tests/beluga_manual_sync.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/gcal_sync_retry.test.d.ts +43 -0
- package/lib/cjs/tests/api_tests/gcal_sync_retry.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/gcal_sync_retry.test.js +168 -0
- package/lib/cjs/tests/api_tests/gcal_sync_retry.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts +23 -0
- package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +325 -0
- package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/user_portal_settings.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/user_portal_settings.test.js +104 -28
- package/lib/cjs/tests/api_tests/user_portal_settings.test.js.map +1 -1
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +444 -174
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/esm/tests/api_tests/beluga_manual_sync.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/beluga_manual_sync.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/beluga_manual_sync.test.js +252 -0
- package/lib/esm/tests/api_tests/beluga_manual_sync.test.js.map +1 -0
- package/lib/esm/tests/api_tests/gcal_sync_retry.test.d.ts +43 -0
- package/lib/esm/tests/api_tests/gcal_sync_retry.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/gcal_sync_retry.test.js +164 -0
- package/lib/esm/tests/api_tests/gcal_sync_retry.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts +23 -0
- package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +321 -0
- package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -0
- package/lib/esm/tests/api_tests/user_portal_settings.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/user_portal_settings.test.js +104 -28
- package/lib/esm/tests/api_tests/user_portal_settings.test.js.map +1 -1
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +444 -174
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/tests/api_tests/beluga_manual_sync.test.ts +159 -0
- package/src/tests/api_tests/gcal_sync_retry.test.ts +104 -0
- package/src/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.ts +214 -0
- package/src/tests/api_tests/user_portal_settings.test.ts +71 -1
- package/src/tests/tests.ts +222 -6
- 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
|
-
|
|
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
|
}
|