@tellescope/sdk 1.252.3 → 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/set_fields_order_templates.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/set_fields_order_templates.test.js +177 -0
- package/lib/cjs/tests/api_tests/set_fields_order_templates.test.js.map +1 -1
- 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/set_fields_order_templates.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/set_fields_order_templates.test.js +177 -0
- package/lib/esm/tests/api_tests/set_fields_order_templates.test.js.map +1 -1
- 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/set_fields_order_templates.test.ts +122 -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
|
+
}
|
|
@@ -228,12 +228,134 @@ const beluga_webhook_block = async ({ sdk }: { sdk: Session }) => {
|
|
|
228
228
|
}
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
// Block C: Beluga tracking status update events (PACKAGE_*)
|
|
232
|
+
const beluga_tracking_status_block = async ({ sdk }: { sdk: Session }) => {
|
|
233
|
+
log_header("Block C: Beluga tracking status updates (PACKAGE_* events)")
|
|
234
|
+
|
|
235
|
+
const webhookUrl = `${host}/v1/webhooks/beluga`
|
|
236
|
+
const externalOrderId = `EXT-C-${Date.now()}`
|
|
237
|
+
const trackingUrl = 'https://tools.usps.com/go/TrackConfirmAction?tLabels=9400-CCC'
|
|
238
|
+
|
|
239
|
+
const enduser = await sdk.api.endusers.createOne({})
|
|
240
|
+
const form = await sdk.api.forms.createOne({ title: 'Tracking Status Beluga Form' })
|
|
241
|
+
const formResponse = await sdk.api.form_responses.createOne({
|
|
242
|
+
formId: form.id,
|
|
243
|
+
enduserId: enduser.id,
|
|
244
|
+
formTitle: form.title,
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
const inTransitTrigger = await sdk.api.automation_triggers.createOne({
|
|
248
|
+
title: `${TRIGGER_TITLE_BLOCK_B} (In Transit)`,
|
|
249
|
+
status: 'Active',
|
|
250
|
+
event: { type: 'Order Status Equals', info: { source: 'Beluga', status: 'In Transit' } },
|
|
251
|
+
action: buildSetFieldsAction('pharmacy'),
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
const postEvent = async (event: string, info?: object) => {
|
|
255
|
+
const res = await fetch(webhookUrl, {
|
|
256
|
+
method: 'POST',
|
|
257
|
+
headers: { 'Content-Type': 'application/json' },
|
|
258
|
+
body: JSON.stringify({
|
|
259
|
+
masterId: `tellescope_${formResponse.id}`,
|
|
260
|
+
event,
|
|
261
|
+
orderId: externalOrderId,
|
|
262
|
+
...info ? { info } : {},
|
|
263
|
+
}),
|
|
264
|
+
})
|
|
265
|
+
assert(res.status === 200, `Beluga webhook (${event}) expected 200, got ${res.status}`)
|
|
266
|
+
await wait(undefined, 250) // webhook upsert + trigger + Set Fields
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const getOrder = async () => {
|
|
270
|
+
const orders = await sdk.api.enduser_orders.getSome({
|
|
271
|
+
filter: { source: 'Beluga', externalId: externalOrderId } as any,
|
|
272
|
+
})
|
|
273
|
+
return orders[0]
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
// Order ships first, then tracking updates arrive
|
|
278
|
+
await postEvent('PHARMACY_ORDER_SHIPPED', { carrier: 'USPS', tracking: '9400-CCC' })
|
|
279
|
+
|
|
280
|
+
await postEvent('PACKAGE_IN_TRANSIT', {
|
|
281
|
+
trackerStatus: 'in_transit',
|
|
282
|
+
trackerId: 'trk_123',
|
|
283
|
+
trackingUrl,
|
|
284
|
+
tracking: '9400-CCC',
|
|
285
|
+
carrier: 'USPS',
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
await async_test(
|
|
289
|
+
"Block C: PACKAGE_IN_TRANSIT fires 'In Transit' trigger with trackingUrl preferred over tracking",
|
|
290
|
+
() => sdk.api.endusers.getOne(enduser.id),
|
|
291
|
+
{ onResult: e => !!(
|
|
292
|
+
e.fields?.pharmacy_status === 'In Transit'
|
|
293
|
+
&& e.fields?.pharmacy_tracking === trackingUrl
|
|
294
|
+
&& e.fields?.pharmacy_carrier === 'USPS'
|
|
295
|
+
&& e.fields?.pharmacy_externalId === externalOrderId
|
|
296
|
+
)}
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
await postEvent('PACKAGE_OUT_FOR_DELIVERY', {
|
|
300
|
+
trackerStatus: 'out_for_delivery',
|
|
301
|
+
trackingUrl,
|
|
302
|
+
carrier: 'USPS',
|
|
303
|
+
})
|
|
304
|
+
await async_test(
|
|
305
|
+
"Block C: PACKAGE_OUT_FOR_DELIVERY sets order status 'Out for Delivery'",
|
|
306
|
+
getOrder,
|
|
307
|
+
{ onResult: o => o?.status === 'Out for Delivery' }
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
const deliveredDate = new Date().toISOString()
|
|
311
|
+
await postEvent('PACKAGE_DELIVERED', {
|
|
312
|
+
trackerStatus: 'delivered',
|
|
313
|
+
trackingUrl,
|
|
314
|
+
carrier: 'USPS',
|
|
315
|
+
deliveredDate,
|
|
316
|
+
})
|
|
317
|
+
await async_test(
|
|
318
|
+
"Block C: PACKAGE_DELIVERED sets status 'Delivered' and stores deliveredDate",
|
|
319
|
+
getOrder,
|
|
320
|
+
{ onResult: o => !!(
|
|
321
|
+
o?.status === 'Delivered'
|
|
322
|
+
&& o?.deliveredDate === deliveredDate
|
|
323
|
+
)}
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
await postEvent('PACKAGE_DELIVERY_FAILED', { trackerStatus: 'failure' })
|
|
327
|
+
await async_test(
|
|
328
|
+
"Block C: PACKAGE_DELIVERY_FAILED sets order status 'Delivery Failed'",
|
|
329
|
+
getOrder,
|
|
330
|
+
{ onResult: o => o?.status === 'Delivery Failed' }
|
|
331
|
+
)
|
|
332
|
+
} finally {
|
|
333
|
+
// Clean up the order created by the webhook
|
|
334
|
+
try {
|
|
335
|
+
const orders = await sdk.api.enduser_orders.getSome({
|
|
336
|
+
filter: { source: 'Beluga', externalId: externalOrderId } as any,
|
|
337
|
+
})
|
|
338
|
+
for (const o of orders) {
|
|
339
|
+
await sdk.api.enduser_orders.deleteOne(o.id).catch(console.error)
|
|
340
|
+
}
|
|
341
|
+
} catch (err) {
|
|
342
|
+
console.error(err)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
await sdk.api.automation_triggers.deleteOne(inTransitTrigger.id).catch(console.error)
|
|
346
|
+
await sdk.api.form_responses.deleteOne(formResponse.id).catch(console.error)
|
|
347
|
+
await sdk.api.forms.deleteOne(form.id).catch(console.error)
|
|
348
|
+
await sdk.api.endusers.deleteOne(enduser.id).catch(console.error)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
231
352
|
export const set_fields_order_templates_tests = async (
|
|
232
353
|
{ sdk, sdkNonAdmin }: { sdk: Session, sdkNonAdmin: Session }
|
|
233
354
|
) => {
|
|
234
355
|
log_header("Set Fields: {{order.*}} template resolution")
|
|
235
356
|
await direct_order_creation_block({ sdk })
|
|
236
357
|
await beluga_webhook_block({ sdk })
|
|
358
|
+
await beluga_tracking_status_block({ sdk })
|
|
237
359
|
}
|
|
238
360
|
|
|
239
361
|
if (require.main === module) {
|