@tellescope/sdk 1.254.0 → 1.254.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/ai_decision_summary_config.test.d.ts +16 -0
- package/lib/cjs/tests/api_tests/ai_decision_summary_config.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/ai_decision_summary_config.test.js +187 -0
- package/lib/cjs/tests/api_tests/ai_decision_summary_config.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/mdi_case_offerings.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/mdi_case_offerings.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/mdi_case_offerings.test.js +443 -0
- package/lib/cjs/tests/api_tests/mdi_case_offerings.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.d.ts +21 -0
- package/lib/cjs/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.js +362 -0
- package/lib/cjs/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0145-globalunique-leak.test.d.ts +20 -0
- package/lib/cjs/tests/api_tests/security/F-0145-globalunique-leak.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0145-globalunique-leak.test.js +139 -0
- package/lib/cjs/tests/api_tests/security/F-0145-globalunique-leak.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0151-load-inbox-redaction.test.d.ts +18 -0
- package/lib/cjs/tests/api_tests/security/F-0151-load-inbox-redaction.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0151-load-inbox-redaction.test.js +156 -0
- package/lib/cjs/tests/api_tests/security/F-0151-load-inbox-redaction.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0155-webhook-timeout.test.d.ts +20 -0
- package/lib/cjs/tests/api_tests/security/F-0155-webhook-timeout.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0155-webhook-timeout.test.js +157 -0
- package/lib/cjs/tests/api_tests/security/F-0155-webhook-timeout.test.js.map +1 -0
- package/lib/cjs/tests/setup.d.ts.map +1 -1
- package/lib/cjs/tests/setup.js +20 -1
- package/lib/cjs/tests/setup.js.map +1 -1
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +175 -155
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/esm/sdk.d.ts +2 -2
- package/lib/esm/tests/api_tests/ai_decision_summary_config.test.d.ts +16 -0
- package/lib/esm/tests/api_tests/ai_decision_summary_config.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/ai_decision_summary_config.test.js +183 -0
- package/lib/esm/tests/api_tests/ai_decision_summary_config.test.js.map +1 -0
- package/lib/esm/tests/api_tests/mdi_case_offerings.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/mdi_case_offerings.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/mdi_case_offerings.test.js +439 -0
- package/lib/esm/tests/api_tests/mdi_case_offerings.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.d.ts +21 -0
- package/lib/esm/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.js +358 -0
- package/lib/esm/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0145-globalunique-leak.test.d.ts +20 -0
- package/lib/esm/tests/api_tests/security/F-0145-globalunique-leak.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0145-globalunique-leak.test.js +135 -0
- package/lib/esm/tests/api_tests/security/F-0145-globalunique-leak.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0151-load-inbox-redaction.test.d.ts +18 -0
- package/lib/esm/tests/api_tests/security/F-0151-load-inbox-redaction.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0151-load-inbox-redaction.test.js +152 -0
- package/lib/esm/tests/api_tests/security/F-0151-load-inbox-redaction.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0155-webhook-timeout.test.d.ts +20 -0
- package/lib/esm/tests/api_tests/security/F-0155-webhook-timeout.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0155-webhook-timeout.test.js +150 -0
- package/lib/esm/tests/api_tests/security/F-0155-webhook-timeout.test.js.map +1 -0
- package/lib/esm/tests/setup.d.ts.map +1 -1
- package/lib/esm/tests/setup.js +20 -1
- package/lib/esm/tests/setup.js.map +1 -1
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +175 -155
- 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/mdi_case_offerings.test.ts +414 -0
- package/src/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.ts +221 -0
- package/src/tests/api_tests/security/F-0145-globalunique-leak.test.ts +78 -0
- package/src/tests/api_tests/security/F-0151-load-inbox-redaction.test.ts +81 -0
- package/src/tests/api_tests/security/F-0155-webhook-timeout.test.ts +85 -0
- package/src/tests/setup.ts +9 -0
- package/src/tests/tests.ts +10 -0
- package/test_generated.pdf +0 -0
|
@@ -0,0 +1,414 @@
|
|
|
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 { evaluate_conditional_logic, evaluate_string_field_comparison } from "@tellescope/utilities"
|
|
10
|
+
import { CompoundFilter } from "@tellescope/types-models"
|
|
11
|
+
|
|
12
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
13
|
+
|
|
14
|
+
type MDIOffering = { offering_id: string, conditions?: CompoundFilter<string> }
|
|
15
|
+
|
|
16
|
+
// Local replica of the API's resolve_case_offerings (md_integrations.ts), built
|
|
17
|
+
// from the same shared @tellescope/utilities helpers it uses, so we can assert
|
|
18
|
+
// the resolution behavior from the SDK test harness.
|
|
19
|
+
const is_empty_conditions = (conditions: CompoundFilter<string>): boolean => {
|
|
20
|
+
if (!conditions || !Object.keys(conditions).length) return true
|
|
21
|
+
const condition = (conditions as { condition?: Record<string, unknown> }).condition
|
|
22
|
+
if (condition !== undefined) return !Object.keys(condition).length
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const resolve_case_offerings = (
|
|
27
|
+
offerings: MDIOffering[],
|
|
28
|
+
responses: { externalId?: string, value?: string }[],
|
|
29
|
+
): MDIOffering[] => (
|
|
30
|
+
offerings.filter(o => {
|
|
31
|
+
if (!o.conditions || is_empty_conditions(o.conditions)) return true
|
|
32
|
+
return evaluate_conditional_logic(o.conditions, (key, value) =>
|
|
33
|
+
evaluate_string_field_comparison(responses.find(r => r.externalId === key)?.value, value)
|
|
34
|
+
)
|
|
35
|
+
})
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
// Local replicas of the API's patient-mapping helpers (md_integrations.ts).
|
|
39
|
+
// SDK tests cannot import the private API package, so we mirror the conversion
|
|
40
|
+
// helpers + exclusion set here to assert the same behavior.
|
|
41
|
+
type GenericQuantityWithUnit = { value: number | string, unit: string }
|
|
42
|
+
|
|
43
|
+
const to_number = (v: unknown): number | undefined => {
|
|
44
|
+
const n = typeof v === 'number' ? v : parseFloat(String(v ?? ''))
|
|
45
|
+
return Number.isFinite(n) ? n : undefined
|
|
46
|
+
}
|
|
47
|
+
const enduser_height_to_cm = (h?: GenericQuantityWithUnit): number | undefined => {
|
|
48
|
+
const inches = to_number(h?.value); if (inches === undefined) return undefined
|
|
49
|
+
return Math.round(inches * 2.54)
|
|
50
|
+
}
|
|
51
|
+
const enduser_weight_to_kg = (w?: GenericQuantityWithUnit): number | undefined => {
|
|
52
|
+
const lbs = to_number(w?.value); if (lbs === undefined) return undefined
|
|
53
|
+
return Math.round(lbs * 0.45359237 * 100) / 100
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const PATIENT_MAPPED_EXTERNAL_IDS = new Set([
|
|
57
|
+
'fname', 'first_name', 'lname', 'last_name',
|
|
58
|
+
'email', 'dateOfBirth', 'gender', 'phone', 'address',
|
|
59
|
+
'height', 'weight',
|
|
60
|
+
'allergies', 'current_medications', 'medical_conditions', 'special_necessities', 'pregnancy',
|
|
61
|
+
'intro_video_id',
|
|
62
|
+
])
|
|
63
|
+
|
|
64
|
+
// Mirrors the externalId exclusion step in form_response_to_case_questions:
|
|
65
|
+
// patient-mapped fields are dropped, everything else survives.
|
|
66
|
+
const case_question_external_ids = (responses: { externalId?: string }[]): string[] => (
|
|
67
|
+
responses
|
|
68
|
+
.filter(r => !(r.externalId && PATIENT_MAPPED_EXTERNAL_IDS.has(r.externalId)))
|
|
69
|
+
.map(r => r.externalId)
|
|
70
|
+
.filter((id): id is string => id !== undefined)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
// Local replica of form_response_to_case_questions' displayed_options logic
|
|
74
|
+
// (md_integrations.ts). SDK tests cannot import the private API package, so we
|
|
75
|
+
// mirror the field lookup (_id first, externalId fallback) + the multiple_choice
|
|
76
|
+
// / Dropdown gate, and assert displayed_options is populated only when applicable.
|
|
77
|
+
type ReplicaField = { _id: string, externalId?: string, options?: { choices?: string[] } }
|
|
78
|
+
type ReplicaResponse = { fieldId?: string, externalId?: string, answer: { type?: string } }
|
|
79
|
+
|
|
80
|
+
const resolve_displayed_options = (
|
|
81
|
+
response: ReplicaResponse,
|
|
82
|
+
formFields: ReplicaField[] = [],
|
|
83
|
+
): string[] | undefined => {
|
|
84
|
+
const field = (
|
|
85
|
+
formFields.find(f => f._id.toString() === response.fieldId)
|
|
86
|
+
|| formFields.find(f => response.externalId && f.externalId === response.externalId)
|
|
87
|
+
)
|
|
88
|
+
return (
|
|
89
|
+
(response.answer.type === 'multiple_choice' || response.answer.type === 'Dropdown')
|
|
90
|
+
&& field?.options?.choices?.length
|
|
91
|
+
) ? field.options.choices : undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const mdi_case_offerings_tests = async ({ sdk, sdkNonAdmin }: { sdk: Session, sdkNonAdmin: Session }) => {
|
|
95
|
+
log_header("MDI Case Offerings Tests")
|
|
96
|
+
|
|
97
|
+
let testFormId: string | undefined
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
// --- Schema round-trip tests ---
|
|
101
|
+
|
|
102
|
+
await async_test(
|
|
103
|
+
"Create form with mdiCaseOfferings mixing unconditional and conditional entries",
|
|
104
|
+
async () => {
|
|
105
|
+
const offerings: MDIOffering[] = [
|
|
106
|
+
{ offering_id: "offering-always" }, // unconditional
|
|
107
|
+
{ offering_id: "offering-ca", conditions: { condition: { state: "CA" } } },
|
|
108
|
+
{
|
|
109
|
+
offering_id: "offering-ca-glp",
|
|
110
|
+
conditions: { $and: [{ condition: { state: "CA" } }, { condition: { med_type: "weightLoss" } }] },
|
|
111
|
+
},
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
const form = await sdk.api.forms.createOne({
|
|
115
|
+
title: 'MDI Case Offerings Test Form',
|
|
116
|
+
mdiCaseOfferings: offerings,
|
|
117
|
+
})
|
|
118
|
+
testFormId = form.id
|
|
119
|
+
|
|
120
|
+
const fetched = await sdk.api.forms.getOne(form.id)
|
|
121
|
+
return (
|
|
122
|
+
fetched.mdiCaseOfferings?.length === 3
|
|
123
|
+
&& fetched.mdiCaseOfferings[0].offering_id === "offering-always"
|
|
124
|
+
&& fetched.mdiCaseOfferings[0].conditions === undefined
|
|
125
|
+
&& !!fetched.mdiCaseOfferings[1].conditions?.condition
|
|
126
|
+
&& !!fetched.mdiCaseOfferings[2].conditions?.$and
|
|
127
|
+
)
|
|
128
|
+
},
|
|
129
|
+
{ expectedResult: true }
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
await async_test(
|
|
133
|
+
"Backwards-compat: existing flat { offering_id } config still validates",
|
|
134
|
+
async () => {
|
|
135
|
+
if (!testFormId) throw new Error("No test form")
|
|
136
|
+
|
|
137
|
+
// mirrors a pre-existing config with no conditions
|
|
138
|
+
const flat: MDIOffering[] = [
|
|
139
|
+
{ offering_id: "legacy-1" },
|
|
140
|
+
{ offering_id: "legacy-2" },
|
|
141
|
+
]
|
|
142
|
+
await sdk.api.forms.updateOne(testFormId, { mdiCaseOfferings: flat }, { replaceObjectFields: true })
|
|
143
|
+
const fetched = await sdk.api.forms.getOne(testFormId)
|
|
144
|
+
return (
|
|
145
|
+
fetched.mdiCaseOfferings?.length === 2
|
|
146
|
+
&& fetched.mdiCaseOfferings.every(o => o.conditions === undefined)
|
|
147
|
+
)
|
|
148
|
+
},
|
|
149
|
+
{ expectedResult: true }
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
await async_test(
|
|
153
|
+
"Save nested CompoundFilter conditions on an offering and read back",
|
|
154
|
+
async () => {
|
|
155
|
+
if (!testFormId) throw new Error("No test form")
|
|
156
|
+
|
|
157
|
+
const nested: MDIOffering[] = [
|
|
158
|
+
{
|
|
159
|
+
offering_id: "offering-nested",
|
|
160
|
+
conditions: {
|
|
161
|
+
$and: [
|
|
162
|
+
{ $or: [{ condition: { state: "CA" } }, { condition: { state: "NY" } }] },
|
|
163
|
+
{ condition: { med_type: "weightLoss" } },
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
]
|
|
168
|
+
await sdk.api.forms.updateOne(testFormId, { mdiCaseOfferings: nested }, { replaceObjectFields: true })
|
|
169
|
+
const fetched = await sdk.api.forms.getOne(testFormId)
|
|
170
|
+
return (
|
|
171
|
+
fetched.mdiCaseOfferings?.length === 1
|
|
172
|
+
&& !!fetched.mdiCaseOfferings[0].conditions?.$and
|
|
173
|
+
)
|
|
174
|
+
},
|
|
175
|
+
{ expectedResult: true }
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
// --- resolve_case_offerings behavior tests ---
|
|
179
|
+
|
|
180
|
+
const MIXED: MDIOffering[] = [
|
|
181
|
+
{ offering_id: "always" }, // unconditional → always sent
|
|
182
|
+
{ offering_id: "ca-only", conditions: { condition: { state: "CA" } } },
|
|
183
|
+
{ offering_id: "ny-only", conditions: { condition: { state: "NY" } } },
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
await async_test(
|
|
187
|
+
"Resolver returns exactly the matching subset plus unconditional offerings",
|
|
188
|
+
async () => {
|
|
189
|
+
const resolved = resolve_case_offerings(MIXED, [{ externalId: "state", value: "CA" }])
|
|
190
|
+
return (
|
|
191
|
+
resolved.length === 2
|
|
192
|
+
&& resolved.some(o => o.offering_id === "always")
|
|
193
|
+
&& resolved.some(o => o.offering_id === "ca-only")
|
|
194
|
+
&& !resolved.some(o => o.offering_id === "ny-only")
|
|
195
|
+
)
|
|
196
|
+
},
|
|
197
|
+
{ expectedResult: true }
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
await async_test(
|
|
201
|
+
"Send none: when conditions match nothing, only unconditional offerings remain",
|
|
202
|
+
async () => {
|
|
203
|
+
// a config where every offering carries a condition that fails
|
|
204
|
+
const allConditional: MDIOffering[] = [
|
|
205
|
+
{ offering_id: "ca-only", conditions: { condition: { state: "CA" } } },
|
|
206
|
+
{ offering_id: "ny-only", conditions: { condition: { state: "NY" } } },
|
|
207
|
+
]
|
|
208
|
+
const resolved = resolve_case_offerings(allConditional, [{ externalId: "state", value: "TX" }])
|
|
209
|
+
return resolved.length === 0
|
|
210
|
+
},
|
|
211
|
+
{ expectedResult: true }
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
await async_test(
|
|
215
|
+
"Backwards compat: all-unconditional config sends everything",
|
|
216
|
+
async () => {
|
|
217
|
+
const allUnconditional: MDIOffering[] = [
|
|
218
|
+
{ offering_id: "a" },
|
|
219
|
+
{ offering_id: "b" },
|
|
220
|
+
{ offering_id: "c" },
|
|
221
|
+
]
|
|
222
|
+
const resolved = resolve_case_offerings(allUnconditional, [{ externalId: "state", value: "TX" }])
|
|
223
|
+
return resolved.length === 3
|
|
224
|
+
},
|
|
225
|
+
{ expectedResult: true }
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
await async_test(
|
|
229
|
+
"Empty/blank conditions object is treated as unconditional (always sent)",
|
|
230
|
+
async () => {
|
|
231
|
+
const withBlank: MDIOffering[] = [
|
|
232
|
+
{ offering_id: "blank", conditions: { condition: {} } },
|
|
233
|
+
]
|
|
234
|
+
// even with no matching response, the blank-condition offering is sent
|
|
235
|
+
const resolved = resolve_case_offerings(withBlank, [])
|
|
236
|
+
return resolved.length === 1 && resolved[0].offering_id === "blank"
|
|
237
|
+
},
|
|
238
|
+
{ expectedResult: true }
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
await async_test(
|
|
242
|
+
"$or conditions: offering sent when any branch matches",
|
|
243
|
+
async () => {
|
|
244
|
+
const offerings: MDIOffering[] = [
|
|
245
|
+
{ offering_id: "ca-or-ny", conditions: { $or: [{ condition: { state: "CA" } }, { condition: { state: "NY" } }] } },
|
|
246
|
+
]
|
|
247
|
+
const resolved = resolve_case_offerings(offerings, [{ externalId: "state", value: "NY" }])
|
|
248
|
+
return resolved.length === 1
|
|
249
|
+
},
|
|
250
|
+
{ expectedResult: true }
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
await async_test(
|
|
254
|
+
"$contains operator narrows offerings by substring match",
|
|
255
|
+
async () => {
|
|
256
|
+
const offerings: MDIOffering[] = [
|
|
257
|
+
{ offering_id: "glp", conditions: { condition: { meds: { $contains: "GLP" } as any } } },
|
|
258
|
+
]
|
|
259
|
+
const matches = resolve_case_offerings(offerings, [{ externalId: "meds", value: "GLP-1 agonist" }])
|
|
260
|
+
const misses = resolve_case_offerings(offerings, [{ externalId: "meds", value: "Semaglutide" }])
|
|
261
|
+
return matches.length === 1 && misses.length === 0
|
|
262
|
+
},
|
|
263
|
+
{ expectedResult: true }
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
// --- height/weight conversion tests ---
|
|
267
|
+
|
|
268
|
+
await async_test(
|
|
269
|
+
"enduser_height_to_cm converts Inches → integer centimeters",
|
|
270
|
+
async () => (
|
|
271
|
+
enduser_height_to_cm({ value: 70, unit: 'Inches' }) === 178
|
|
272
|
+
&& enduser_height_to_cm({ value: '70', unit: 'Inches' }) === 178 // string value parsed
|
|
273
|
+
&& enduser_height_to_cm(undefined) === undefined // absent → omitted
|
|
274
|
+
&& enduser_height_to_cm({ value: '', unit: 'Inches' }) === undefined
|
|
275
|
+
),
|
|
276
|
+
{ expectedResult: true }
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
await async_test(
|
|
280
|
+
"enduser_weight_to_kg converts Pounds → kilograms (float, 2dp)",
|
|
281
|
+
async () => (
|
|
282
|
+
enduser_weight_to_kg({ value: 200, unit: 'Pounds' }) === 90.72
|
|
283
|
+
&& enduser_weight_to_kg({ value: '200', unit: 'Pounds' }) === 90.72 // string value parsed
|
|
284
|
+
&& enduser_weight_to_kg(undefined) === undefined // absent → omitted
|
|
285
|
+
&& enduser_weight_to_kg({ value: 'not-a-number', unit: 'Pounds' }) === undefined
|
|
286
|
+
),
|
|
287
|
+
{ expectedResult: true }
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
// --- case_questions exclusion tests ---
|
|
291
|
+
|
|
292
|
+
await async_test(
|
|
293
|
+
"case_questions exclude patient-mapped fields but keep non-mapped clinical answers",
|
|
294
|
+
async () => {
|
|
295
|
+
const responses = [
|
|
296
|
+
{ externalId: 'fname' },
|
|
297
|
+
{ externalId: 'email' },
|
|
298
|
+
{ externalId: 'dateOfBirth' },
|
|
299
|
+
{ externalId: 'gender' },
|
|
300
|
+
{ externalId: 'phone' },
|
|
301
|
+
{ externalId: 'address' },
|
|
302
|
+
{ externalId: 'height' },
|
|
303
|
+
{ externalId: 'weight' },
|
|
304
|
+
{ externalId: 'allergies' },
|
|
305
|
+
{ externalId: 'special_necessities' },
|
|
306
|
+
{ externalId: 'symptoms' }, // non-mapped clinical field → survives
|
|
307
|
+
{ externalId: 'duration' }, // non-mapped clinical field → survives
|
|
308
|
+
]
|
|
309
|
+
const kept = case_question_external_ids(responses)
|
|
310
|
+
const excluded = ['fname', 'email', 'dateOfBirth', 'gender', 'phone', 'address', 'height', 'weight', 'allergies', 'special_necessities']
|
|
311
|
+
return (
|
|
312
|
+
kept.length === 2
|
|
313
|
+
&& kept.includes('symptoms')
|
|
314
|
+
&& kept.includes('duration')
|
|
315
|
+
&& excluded.every(id => !kept.includes(id))
|
|
316
|
+
)
|
|
317
|
+
},
|
|
318
|
+
{ expectedResult: true }
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
// --- displayed_options tests ---
|
|
322
|
+
|
|
323
|
+
await async_test(
|
|
324
|
+
"multiple_choice response + matching field by _id → displayed_options equals field choices",
|
|
325
|
+
async () => {
|
|
326
|
+
const fields: ReplicaField[] = [
|
|
327
|
+
{ _id: 'field-1', options: { choices: ['Yes', 'No', 'Maybe'] } },
|
|
328
|
+
]
|
|
329
|
+
const response: ReplicaResponse = { fieldId: 'field-1', answer: { type: 'multiple_choice' } }
|
|
330
|
+
const opts = resolve_displayed_options(response, fields)
|
|
331
|
+
return (
|
|
332
|
+
!!opts
|
|
333
|
+
&& opts.length === 3
|
|
334
|
+
&& opts[0] === 'Yes' && opts[1] === 'No' && opts[2] === 'Maybe'
|
|
335
|
+
)
|
|
336
|
+
},
|
|
337
|
+
{ expectedResult: true }
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
await async_test(
|
|
341
|
+
"Dropdown response + matching field by externalId → displayed_options equals field choices",
|
|
342
|
+
async () => {
|
|
343
|
+
const fields: ReplicaField[] = [
|
|
344
|
+
{ _id: 'field-2', externalId: 'state', options: { choices: ['CA', 'NY', 'TX'] } },
|
|
345
|
+
]
|
|
346
|
+
// fieldId does not match any field; externalId fallback resolves the field
|
|
347
|
+
const response: ReplicaResponse = { fieldId: '', externalId: 'state', answer: { type: 'Dropdown' } }
|
|
348
|
+
const opts = resolve_displayed_options(response, fields)
|
|
349
|
+
return !!opts && opts.length === 3 && opts.join(',') === 'CA,NY,TX'
|
|
350
|
+
},
|
|
351
|
+
{ expectedResult: true }
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
await async_test(
|
|
355
|
+
"string/number response → displayed_options undefined even with field choices present",
|
|
356
|
+
async () => {
|
|
357
|
+
const fields: ReplicaField[] = [
|
|
358
|
+
{ _id: 'field-3', options: { choices: ['Yes', 'No'] } },
|
|
359
|
+
]
|
|
360
|
+
const stringResponse: ReplicaResponse = { fieldId: 'field-3', answer: { type: 'string' } }
|
|
361
|
+
const numberResponse: ReplicaResponse = { fieldId: 'field-3', answer: { type: 'number' } }
|
|
362
|
+
return (
|
|
363
|
+
resolve_displayed_options(stringResponse, fields) === undefined
|
|
364
|
+
&& resolve_displayed_options(numberResponse, fields) === undefined
|
|
365
|
+
)
|
|
366
|
+
},
|
|
367
|
+
{ expectedResult: true }
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
await async_test(
|
|
371
|
+
"multiple_choice response with no matching field → displayed_options undefined (question still kept)",
|
|
372
|
+
async () => {
|
|
373
|
+
const fields: ReplicaField[] = [
|
|
374
|
+
{ _id: 'field-4', externalId: 'other', options: { choices: ['A', 'B'] } },
|
|
375
|
+
]
|
|
376
|
+
// neither fieldId nor externalId matches any field
|
|
377
|
+
const noMatch: ReplicaResponse = { fieldId: 'field-unknown', externalId: 'unknown', answer: { type: 'multiple_choice' } }
|
|
378
|
+
// matching field but empty choices → also undefined
|
|
379
|
+
const emptyChoices: ReplicaResponse = { fieldId: 'field-5', answer: { type: 'multiple_choice' } }
|
|
380
|
+
const fieldsEmpty: ReplicaField[] = [{ _id: 'field-5', options: { choices: [] } }]
|
|
381
|
+
return (
|
|
382
|
+
resolve_displayed_options(noMatch, fields) === undefined
|
|
383
|
+
&& resolve_displayed_options(emptyChoices, fieldsEmpty) === undefined
|
|
384
|
+
)
|
|
385
|
+
},
|
|
386
|
+
{ expectedResult: true }
|
|
387
|
+
)
|
|
388
|
+
} finally {
|
|
389
|
+
if (testFormId) {
|
|
390
|
+
await sdk.api.forms.deleteOne(testFormId).catch(console.error)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (require.main === module) {
|
|
396
|
+
console.log(`Using API URL: ${host}`)
|
|
397
|
+
const sdk = new Session({ host })
|
|
398
|
+
const sdkNonAdmin = new Session({ host })
|
|
399
|
+
|
|
400
|
+
const runTests = async () => {
|
|
401
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
402
|
+
await mdi_case_offerings_tests({ sdk, sdkNonAdmin })
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
runTests()
|
|
406
|
+
.then(() => {
|
|
407
|
+
console.log("MDI case offerings test suite completed successfully")
|
|
408
|
+
process.exit(0)
|
|
409
|
+
})
|
|
410
|
+
.catch((error) => {
|
|
411
|
+
console.error("MDI case offerings test suite failed:", error)
|
|
412
|
+
process.exit(1)
|
|
413
|
+
})
|
|
414
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
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 = 'F0116TestPassword!123'
|
|
14
|
+
|
|
15
|
+
const CROSS_ENDUSER_MESSAGE = "enduserId does not match creator id for enduser session"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Regression test for the enduser cross-enduser CREATE gating gaps:
|
|
19
|
+
* F-0116 enduser_observations (security-audit/findings/F-0116-*.md)
|
|
20
|
+
* F-0117 enduser_orders (security-audit/findings/F-0117-*.md)
|
|
21
|
+
* F-0119 managed_content_records (security-audit/findings/F-0119-*.md)
|
|
22
|
+
* F-0118 chat_rooms (enduserIds-only gate; staff userIds targeting is by-design)
|
|
23
|
+
*
|
|
24
|
+
* Each model exposed enduserActions.create with an `enduserId`/`enduserIds` field that
|
|
25
|
+
* lacked a write-side gate, letting an authenticated enduser create records bound to a
|
|
26
|
+
* DIFFERENT enduser in the same tenant. The fix adds a `constraints.relationship`
|
|
27
|
+
* evaluator mirroring the canonical `tickets` pattern (schema.ts).
|
|
28
|
+
*
|
|
29
|
+
* Methodology: each section asserts BOTH the exploit-is-blocked case AND that the
|
|
30
|
+
* legitimate path (self-create + staff-create) still works.
|
|
31
|
+
*/
|
|
32
|
+
export const enduser_cross_create_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
33
|
+
log_header("F-0116/F-0117/F-0118/F-0119: enduser cross-enduser create gates")
|
|
34
|
+
|
|
35
|
+
let attackerId: string | undefined
|
|
36
|
+
let victimId: string | undefined
|
|
37
|
+
const createdObservationIds: string[] = []
|
|
38
|
+
const createdOrderIds: string[] = []
|
|
39
|
+
const createdContentIds: string[] = []
|
|
40
|
+
const createdRoomIds: string[] = []
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
// ---- Setup: attacker enduser (with portal session) + victim enduser ----
|
|
44
|
+
const attackerEmail = `f0116-attacker-${Date.now()}@example.com`
|
|
45
|
+
const victimEmail = `f0116-victim-${Date.now()}@example.com`
|
|
46
|
+
const attacker = await sdk.api.endusers.createOne({ email: attackerEmail, fname: 'F0116', lname: 'Attacker' })
|
|
47
|
+
attackerId = attacker.id
|
|
48
|
+
const victim = await sdk.api.endusers.createOne({ email: victimEmail, fname: 'F0116', lname: 'Victim' })
|
|
49
|
+
victimId = victim.id
|
|
50
|
+
await sdk.api.endusers.set_password({ id: attacker.id, password: ENDUSER_PASSWORD })
|
|
51
|
+
|
|
52
|
+
const attackerSDK = new EnduserSession({ host, businessId })
|
|
53
|
+
await attackerSDK.authenticate(attackerEmail, ENDUSER_PASSWORD)
|
|
54
|
+
|
|
55
|
+
// ============================================================
|
|
56
|
+
// F-0116 — enduser_observations
|
|
57
|
+
// ============================================================
|
|
58
|
+
await async_test(
|
|
59
|
+
`F-0116: enduser cannot create observation on another enduser (createOne)`,
|
|
60
|
+
() => attackerSDK.api.enduser_observations.createOne({
|
|
61
|
+
enduserId: victimId!, category: 'vital-signs', status: 'final',
|
|
62
|
+
measurement: { unit: 'mmHg', value: 220 },
|
|
63
|
+
} as any),
|
|
64
|
+
{ shouldError: true, onError: e => e.message === CROSS_ENDUSER_MESSAGE },
|
|
65
|
+
)
|
|
66
|
+
await async_test(
|
|
67
|
+
`F-0116: enduser cannot create observation on another enduser (createMany)`,
|
|
68
|
+
() => attackerSDK.api.enduser_observations.createSome([{
|
|
69
|
+
enduserId: victimId!, category: 'vital-signs', status: 'final',
|
|
70
|
+
measurement: { unit: 'mmHg', value: 220 },
|
|
71
|
+
}] as any),
|
|
72
|
+
{ shouldError: true, onError: e => e.message === CROSS_ENDUSER_MESSAGE },
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
// Positive control: enduser CAN create an observation on their OWN record
|
|
76
|
+
await async_test(
|
|
77
|
+
`F-0116: enduser can still create observation on own record`,
|
|
78
|
+
() => attackerSDK.api.enduser_observations.createOne({
|
|
79
|
+
enduserId: attackerId!, category: 'vital-signs', status: 'final',
|
|
80
|
+
measurement: { unit: 'mmHg', value: 120 },
|
|
81
|
+
} as any),
|
|
82
|
+
{ onResult: (o: any) => { if (o?.id) createdObservationIds.push(o.id); return o?.enduserId === attackerId } },
|
|
83
|
+
)
|
|
84
|
+
// Positive control: staff CAN create an observation on any tenant enduser
|
|
85
|
+
await async_test(
|
|
86
|
+
`F-0116: staff can still create observation on any enduser`,
|
|
87
|
+
() => sdk.api.enduser_observations.createOne({
|
|
88
|
+
enduserId: victimId!, category: 'vital-signs', status: 'final',
|
|
89
|
+
measurement: { unit: 'mmHg', value: 118 },
|
|
90
|
+
} as any),
|
|
91
|
+
{ onResult: (o: any) => { if (o?.id) createdObservationIds.push(o.id); return o?.enduserId === victimId } },
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
// ============================================================
|
|
95
|
+
// F-0117 — enduser_orders
|
|
96
|
+
// ============================================================
|
|
97
|
+
const orderPayload = (enduserId: string) => ({
|
|
98
|
+
enduserId, externalId: `f0117-${Date.now()}`, source: 'f0117-source',
|
|
99
|
+
title: 'f0117 order', status: 'pending',
|
|
100
|
+
})
|
|
101
|
+
await async_test(
|
|
102
|
+
`F-0117: enduser cannot create order on another enduser`,
|
|
103
|
+
() => attackerSDK.api.enduser_orders.createOne(orderPayload(victimId!) as any),
|
|
104
|
+
{ shouldError: true, onError: e => e.message === CROSS_ENDUSER_MESSAGE },
|
|
105
|
+
)
|
|
106
|
+
await async_test(
|
|
107
|
+
`F-0117: enduser can still create order on own record`,
|
|
108
|
+
() => attackerSDK.api.enduser_orders.createOne(orderPayload(attackerId!) as any),
|
|
109
|
+
{ onResult: (o: any) => { if (o?.id) createdOrderIds.push(o.id); return o?.enduserId === attackerId } },
|
|
110
|
+
)
|
|
111
|
+
await async_test(
|
|
112
|
+
`F-0117: staff can still create order on any enduser`,
|
|
113
|
+
() => sdk.api.enduser_orders.createOne(orderPayload(victimId!) as any),
|
|
114
|
+
{ onResult: (o: any) => { if (o?.id) createdOrderIds.push(o.id); return o?.enduserId === victimId } },
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
// ============================================================
|
|
118
|
+
// F-0119 — managed_content_records
|
|
119
|
+
// ============================================================
|
|
120
|
+
await async_test(
|
|
121
|
+
`F-0119: enduser cannot create content with allowUnauthenticatedAccess`,
|
|
122
|
+
() => attackerSDK.api.managed_content_records.createOne({
|
|
123
|
+
title: 'f0119 phishing', textContent: 'x', allowUnauthenticatedAccess: true,
|
|
124
|
+
} as any),
|
|
125
|
+
{ shouldError: true, onError: e => e.message === "allowUnauthenticatedAccess cannot be set by endusers" },
|
|
126
|
+
)
|
|
127
|
+
await async_test(
|
|
128
|
+
`F-0119: enduser cannot create content with publicRead`,
|
|
129
|
+
() => attackerSDK.api.managed_content_records.createOne({
|
|
130
|
+
title: 'f0119 broadcast', textContent: 'x', publicRead: true,
|
|
131
|
+
} as any),
|
|
132
|
+
{ shouldError: true, onError: e => e.message === "publicRead cannot be set by endusers" },
|
|
133
|
+
)
|
|
134
|
+
await async_test(
|
|
135
|
+
`F-0119: enduser cannot create content bound to another enduser`,
|
|
136
|
+
() => attackerSDK.api.managed_content_records.createOne({
|
|
137
|
+
title: 'f0119 cross', textContent: 'x', enduserId: victimId!,
|
|
138
|
+
} as any),
|
|
139
|
+
{ shouldError: true, onError: e => e.message === CROSS_ENDUSER_MESSAGE },
|
|
140
|
+
)
|
|
141
|
+
await async_test(
|
|
142
|
+
`F-0119: enduser can still create ordinary content`,
|
|
143
|
+
() => attackerSDK.api.managed_content_records.createOne({
|
|
144
|
+
title: 'f0119 ok', textContent: 'hello',
|
|
145
|
+
} as any),
|
|
146
|
+
{ onResult: (o: any) => { if (o?.id) createdContentIds.push(o.id); return !o?.publicRead && !o?.allowUnauthenticatedAccess } },
|
|
147
|
+
)
|
|
148
|
+
await async_test(
|
|
149
|
+
`F-0119: staff can still create public content`,
|
|
150
|
+
() => sdk.api.managed_content_records.createOne({
|
|
151
|
+
title: 'f0119 staff public', textContent: 'x', publicRead: true,
|
|
152
|
+
} as any),
|
|
153
|
+
{ onResult: (o: any) => { if (o?.id) createdContentIds.push(o.id); return o?.publicRead === true } },
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
// ============================================================
|
|
157
|
+
// F-0118 — chat_rooms (enduserIds-only gate; staff userIds targeting is by-design)
|
|
158
|
+
// ============================================================
|
|
159
|
+
const staffId = sdk.userInfo.id
|
|
160
|
+
await async_test(
|
|
161
|
+
`F-0118: enduser cannot add another patient to a chat room (enduserIds)`,
|
|
162
|
+
() => attackerSDK.api.chat_rooms.createOne({
|
|
163
|
+
type: 'internal', enduserIds: [attackerId!, victimId!], userIds: [staffId],
|
|
164
|
+
} as any),
|
|
165
|
+
{ shouldError: true, onError: e => e.message === "enduserIds may only contain your own id for enduser session" },
|
|
166
|
+
)
|
|
167
|
+
// Positive control: patient CAN create a room with care-team staff + self (intended feature)
|
|
168
|
+
await async_test(
|
|
169
|
+
`F-0118: enduser can still create a room with staff + self`,
|
|
170
|
+
() => attackerSDK.api.chat_rooms.createOne({
|
|
171
|
+
type: 'internal', enduserIds: [attackerId!], userIds: [staffId],
|
|
172
|
+
} as any),
|
|
173
|
+
{ onResult: (o: any) => { if (o?.id) createdRoomIds.push(o.id); return o?.enduserIds?.length === 1 && o?.userIds?.includes(staffId) } },
|
|
174
|
+
)
|
|
175
|
+
// Positive control: staff can still create multi-patient rooms
|
|
176
|
+
await async_test(
|
|
177
|
+
`F-0118: staff can still create a room with multiple endusers`,
|
|
178
|
+
() => sdk.api.chat_rooms.createOne({
|
|
179
|
+
type: 'internal', enduserIds: [attackerId!, victimId!], userIds: [staffId],
|
|
180
|
+
} as any),
|
|
181
|
+
{ onResult: (o: any) => { if (o?.id) createdRoomIds.push(o.id); return o?.enduserIds?.length === 2 } },
|
|
182
|
+
)
|
|
183
|
+
} finally {
|
|
184
|
+
for (const id of createdRoomIds) {
|
|
185
|
+
try { await sdk.api.chat_rooms.deleteOne(id) } catch {}
|
|
186
|
+
}
|
|
187
|
+
for (const id of createdContentIds) {
|
|
188
|
+
try { await sdk.api.managed_content_records.deleteOne(id) } catch {}
|
|
189
|
+
}
|
|
190
|
+
for (const id of createdObservationIds) {
|
|
191
|
+
try { await sdk.api.enduser_observations.deleteOne(id) } catch {}
|
|
192
|
+
}
|
|
193
|
+
for (const id of createdOrderIds) {
|
|
194
|
+
try { await sdk.api.enduser_orders.deleteOne(id) } catch {}
|
|
195
|
+
}
|
|
196
|
+
if (attackerId) { try { await sdk.api.endusers.deleteOne(attackerId) } catch {} }
|
|
197
|
+
if (victimId) { try { await sdk.api.endusers.deleteOne(victimId) } catch {} }
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Allow running this test file independently
|
|
202
|
+
if (require.main === module) {
|
|
203
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
204
|
+
const sdk = new Session({ host })
|
|
205
|
+
const sdkNonAdmin = new Session({ host })
|
|
206
|
+
|
|
207
|
+
const runTests = async () => {
|
|
208
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
209
|
+
await enduser_cross_create_tests({ sdk, sdkNonAdmin })
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
runTests()
|
|
213
|
+
.then(() => {
|
|
214
|
+
console.log("✅ F-0116/F-0117/F-0118/F-0119 enduser cross-create test suite completed successfully")
|
|
215
|
+
process.exit(0)
|
|
216
|
+
})
|
|
217
|
+
.catch((error) => {
|
|
218
|
+
console.error("❌ enduser cross-create test suite failed:", error)
|
|
219
|
+
process.exit(1)
|
|
220
|
+
})
|
|
221
|
+
}
|