@tellescope/sdk 1.254.0 → 1.255.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/lib/cjs/sdk.d.ts +4 -0
  2. package/lib/cjs/sdk.d.ts.map +1 -1
  3. package/lib/cjs/sdk.js +5 -0
  4. package/lib/cjs/sdk.js.map +1 -1
  5. package/lib/cjs/tests/api_tests/ai_decision_summary_config.test.d.ts +16 -0
  6. package/lib/cjs/tests/api_tests/ai_decision_summary_config.test.d.ts.map +1 -0
  7. package/lib/cjs/tests/api_tests/ai_decision_summary_config.test.js +187 -0
  8. package/lib/cjs/tests/api_tests/ai_decision_summary_config.test.js.map +1 -0
  9. package/lib/cjs/tests/api_tests/mdi_case_offerings.test.d.ts +6 -0
  10. package/lib/cjs/tests/api_tests/mdi_case_offerings.test.d.ts.map +1 -0
  11. package/lib/cjs/tests/api_tests/mdi_case_offerings.test.js +443 -0
  12. package/lib/cjs/tests/api_tests/mdi_case_offerings.test.js.map +1 -0
  13. package/lib/cjs/tests/api_tests/order_status_equals_frequency.test.d.ts +6 -0
  14. package/lib/cjs/tests/api_tests/order_status_equals_frequency.test.d.ts.map +1 -0
  15. package/lib/cjs/tests/api_tests/order_status_equals_frequency.test.js +254 -0
  16. package/lib/cjs/tests/api_tests/order_status_equals_frequency.test.js.map +1 -0
  17. package/lib/cjs/tests/api_tests/organization_api_keys.test.d.ts +6 -0
  18. package/lib/cjs/tests/api_tests/organization_api_keys.test.d.ts.map +1 -0
  19. package/lib/cjs/tests/api_tests/organization_api_keys.test.js +226 -0
  20. package/lib/cjs/tests/api_tests/organization_api_keys.test.js.map +1 -0
  21. package/lib/cjs/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.d.ts +21 -0
  22. package/lib/cjs/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.d.ts.map +1 -0
  23. package/lib/cjs/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.js +362 -0
  24. package/lib/cjs/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.js.map +1 -0
  25. package/lib/cjs/tests/api_tests/security/F-0145-globalunique-leak.test.d.ts +20 -0
  26. package/lib/cjs/tests/api_tests/security/F-0145-globalunique-leak.test.d.ts.map +1 -0
  27. package/lib/cjs/tests/api_tests/security/F-0145-globalunique-leak.test.js +139 -0
  28. package/lib/cjs/tests/api_tests/security/F-0145-globalunique-leak.test.js.map +1 -0
  29. package/lib/cjs/tests/api_tests/security/F-0151-load-inbox-redaction.test.d.ts +18 -0
  30. package/lib/cjs/tests/api_tests/security/F-0151-load-inbox-redaction.test.d.ts.map +1 -0
  31. package/lib/cjs/tests/api_tests/security/F-0151-load-inbox-redaction.test.js +156 -0
  32. package/lib/cjs/tests/api_tests/security/F-0151-load-inbox-redaction.test.js.map +1 -0
  33. package/lib/cjs/tests/api_tests/security/F-0155-webhook-timeout.test.d.ts +20 -0
  34. package/lib/cjs/tests/api_tests/security/F-0155-webhook-timeout.test.d.ts.map +1 -0
  35. package/lib/cjs/tests/api_tests/security/F-0155-webhook-timeout.test.js +157 -0
  36. package/lib/cjs/tests/api_tests/security/F-0155-webhook-timeout.test.js.map +1 -0
  37. package/lib/cjs/tests/setup.d.ts.map +1 -1
  38. package/lib/cjs/tests/setup.js +20 -1
  39. package/lib/cjs/tests/setup.js.map +1 -1
  40. package/lib/cjs/tests/tests.d.ts.map +1 -1
  41. package/lib/cjs/tests/tests.js +543 -282
  42. package/lib/cjs/tests/tests.js.map +1 -1
  43. package/lib/esm/sdk.d.ts +6 -2
  44. package/lib/esm/sdk.d.ts.map +1 -1
  45. package/lib/esm/sdk.js +5 -0
  46. package/lib/esm/sdk.js.map +1 -1
  47. package/lib/esm/session.d.ts +1 -0
  48. package/lib/esm/session.d.ts.map +1 -1
  49. package/lib/esm/tests/api_tests/ai_decision_summary_config.test.d.ts +16 -0
  50. package/lib/esm/tests/api_tests/ai_decision_summary_config.test.d.ts.map +1 -0
  51. package/lib/esm/tests/api_tests/ai_decision_summary_config.test.js +183 -0
  52. package/lib/esm/tests/api_tests/ai_decision_summary_config.test.js.map +1 -0
  53. package/lib/esm/tests/api_tests/mdi_case_offerings.test.d.ts +6 -0
  54. package/lib/esm/tests/api_tests/mdi_case_offerings.test.d.ts.map +1 -0
  55. package/lib/esm/tests/api_tests/mdi_case_offerings.test.js +439 -0
  56. package/lib/esm/tests/api_tests/mdi_case_offerings.test.js.map +1 -0
  57. package/lib/esm/tests/api_tests/order_status_equals_frequency.test.d.ts +6 -0
  58. package/lib/esm/tests/api_tests/order_status_equals_frequency.test.d.ts.map +1 -0
  59. package/lib/esm/tests/api_tests/order_status_equals_frequency.test.js +250 -0
  60. package/lib/esm/tests/api_tests/order_status_equals_frequency.test.js.map +1 -0
  61. package/lib/esm/tests/api_tests/organization_api_keys.test.d.ts +6 -0
  62. package/lib/esm/tests/api_tests/organization_api_keys.test.d.ts.map +1 -0
  63. package/lib/esm/tests/api_tests/organization_api_keys.test.js +222 -0
  64. package/lib/esm/tests/api_tests/organization_api_keys.test.js.map +1 -0
  65. package/lib/esm/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.d.ts +21 -0
  66. package/lib/esm/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.d.ts.map +1 -0
  67. package/lib/esm/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.js +358 -0
  68. package/lib/esm/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.js.map +1 -0
  69. package/lib/esm/tests/api_tests/security/F-0145-globalunique-leak.test.d.ts +20 -0
  70. package/lib/esm/tests/api_tests/security/F-0145-globalunique-leak.test.d.ts.map +1 -0
  71. package/lib/esm/tests/api_tests/security/F-0145-globalunique-leak.test.js +135 -0
  72. package/lib/esm/tests/api_tests/security/F-0145-globalunique-leak.test.js.map +1 -0
  73. package/lib/esm/tests/api_tests/security/F-0151-load-inbox-redaction.test.d.ts +18 -0
  74. package/lib/esm/tests/api_tests/security/F-0151-load-inbox-redaction.test.d.ts.map +1 -0
  75. package/lib/esm/tests/api_tests/security/F-0151-load-inbox-redaction.test.js +152 -0
  76. package/lib/esm/tests/api_tests/security/F-0151-load-inbox-redaction.test.js.map +1 -0
  77. package/lib/esm/tests/api_tests/security/F-0155-webhook-timeout.test.d.ts +20 -0
  78. package/lib/esm/tests/api_tests/security/F-0155-webhook-timeout.test.d.ts.map +1 -0
  79. package/lib/esm/tests/api_tests/security/F-0155-webhook-timeout.test.js +150 -0
  80. package/lib/esm/tests/api_tests/security/F-0155-webhook-timeout.test.js.map +1 -0
  81. package/lib/esm/tests/setup.d.ts.map +1 -1
  82. package/lib/esm/tests/setup.js +20 -1
  83. package/lib/esm/tests/setup.js.map +1 -1
  84. package/lib/esm/tests/tests.d.ts.map +1 -1
  85. package/lib/esm/tests/tests.js +543 -282
  86. package/lib/esm/tests/tests.js.map +1 -1
  87. package/lib/tsconfig.tsbuildinfo +1 -1
  88. package/package.json +10 -10
  89. package/src/sdk.ts +11 -0
  90. package/src/tests/api_tests/mdi_case_offerings.test.ts +414 -0
  91. package/src/tests/api_tests/organization_api_keys.test.ts +207 -0
  92. package/src/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.ts +221 -0
  93. package/src/tests/api_tests/security/F-0145-globalunique-leak.test.ts +78 -0
  94. package/src/tests/api_tests/security/F-0151-load-inbox-redaction.test.ts +81 -0
  95. package/src/tests/api_tests/security/F-0155-webhook-timeout.test.ts +85 -0
  96. package/src/tests/setup.ts +9 -0
  97. package/src/tests/tests.ts +199 -2
  98. 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,207 @@
1
+ require('source-map-support').install();
2
+
3
+ import { Session } from "../../sdk"
4
+ import {
5
+ assert,
6
+ async_test,
7
+ log_header,
8
+ wait,
9
+ } from "@tellescope/testing"
10
+ import { setup_tests } from "../setup"
11
+
12
+ const host = process.env.API_URL || 'http://localhost:8080' as const
13
+
14
+ // fields that the admin org-wide list endpoint is allowed to return
15
+ const ALLOWED_FIELDS = ['id', 'creator', 'businessId', 'updatedAt'].sort()
16
+
17
+ export const organization_api_keys_tests = async (
18
+ { sdk, sdkNonAdmin }: { sdk: Session, sdkNonAdmin: Session }
19
+ ) => {
20
+ log_header("Organization API Keys (admin org-wide management)")
21
+
22
+ // We never mutate sdkNonAdmin's roles here (throwaway-user rule). Instead we create a
23
+ // dedicated throwaway user in the SAME org that DOES have api_keys access (via a custom
24
+ // role), so it can create its own key (the offboarded-employee scenario) and so the
25
+ // admin-gating negative tests below exercise the adminOnly gate (401) rather than the
26
+ // access-permission gate (403) — a non-admin without api_keys access is rejected with
27
+ // "Inaccessible" before adminOnly is ever reached.
28
+ const adminKeyIds: string[] = []
29
+ let otherKeyId: string | undefined
30
+ let otherUserId: string | undefined
31
+ let apiKeysRoleId: string | undefined
32
+
33
+ try {
34
+ // A custom role that grants api_keys access (merged over PROVIDER_PERMISSIONS server-side)
35
+ const apiKeysRole = await sdk.api.role_based_access_permissions.createOne({
36
+ role: 'api-keys-test-role',
37
+ permissions: { api_keys: { create: 'All', read: 'All', update: 'All', delete: 'All' } },
38
+ })
39
+ apiKeysRoleId = apiKeysRole.id
40
+
41
+ // A throwaway non-admin user in the same org, holding that role
42
+ const otherUser = (
43
+ await sdk.api.users.getOne({ email: 'api.keys.other.user@tellescope.com' }).catch(() => null)
44
+ ) || (
45
+ await sdk.api.users.createOne({ email: 'api.keys.other.user@tellescope.com', notificationEmailsDisabled: true, verifiedEmail: true })
46
+ )
47
+ otherUserId = otherUser.id
48
+ await sdk.api.users.updateOne(otherUser.id, { roles: [apiKeysRole.role] }, { replaceObjectFields: true })
49
+ await wait(undefined, 2000) // wait for the role change to propagate before authenticating
50
+
51
+ const otherSdk = new Session({
52
+ host,
53
+ authToken: (await sdk.api.users.generate_auth_token({ id: otherUser.id })).authToken,
54
+ })
55
+
56
+ // A key created by the admin...
57
+ const adminKey = await sdk.api.api_keys.createOne({})
58
+ adminKeyIds.push(adminKey.id)
59
+
60
+ // ...and a key created by ANOTHER user in the org (the offboarded-employee scenario)
61
+ const otherKey = await otherSdk.api.api_keys.createOne({})
62
+ otherKeyId = otherKey.id
63
+
64
+ // =============================================
65
+ // CORE: admin sees keys created by other users
66
+ // =============================================
67
+ const { apiKeys } = await sdk.api.api_keys.get_organization_api_keys()
68
+
69
+ assert(
70
+ apiKeys.some(k => k.id === otherKeyId),
71
+ `admin list did not include another user's key ${otherKeyId}`,
72
+ "admin get_organization_api_keys returns keys created by other users in the org"
73
+ )
74
+ assert(
75
+ apiKeys.some(k => k.id === adminKey.id),
76
+ "admin list did not include the admin's own key",
77
+ "admin get_organization_api_keys returns the admin's own keys"
78
+ )
79
+ assert(
80
+ !!apiKeys.find(k => k.id === otherKeyId && k.creator === otherUser.id),
81
+ "creator on the other user's key did not match",
82
+ "returned key reports the correct creator"
83
+ )
84
+
85
+ // =============================================
86
+ // SECRET REDACTION (server-side, allowlist)
87
+ // =============================================
88
+ assert(
89
+ apiKeys.every(k => (k as any).key === undefined),
90
+ "a returned record exposed a plaintext `key`",
91
+ "no returned record exposes a plaintext key (legacy field)"
92
+ )
93
+ assert(
94
+ apiKeys.every(k => (k as any).hashedKey === undefined),
95
+ "a returned record exposed `hashedKey`",
96
+ "no returned record exposes hashedKey"
97
+ )
98
+ // Strict allowlist: every returned field must be in the allowlist (no extra/sensitive field
99
+ // ever leaks). We can't require all allowlisted fields to be PRESENT — never-updated keys
100
+ // have no `updatedAt`, and res.json() drops undefined values — so this is a subset check.
101
+ const offending = apiKeys
102
+ .map(k => Object.keys(k).filter(key => !ALLOWED_FIELDS.includes(key)))
103
+ .find(extra => extra.length > 0)
104
+ assert(
105
+ offending === undefined,
106
+ `a returned record exposed non-allowlisted fields: ${JSON.stringify(offending)}`,
107
+ "returned records contain only allowlisted fields (subset of id, creator, businessId, updatedAt)"
108
+ )
109
+
110
+ // =============================================
111
+ // CROSS-ORG ISOLATION (no leakage across businesses)
112
+ // =============================================
113
+ assert(
114
+ apiKeys.every(k => k.businessId === sdk.userInfo.businessId),
115
+ "a returned key belonged to a different business",
116
+ "all returned keys are scoped to the admin's businessId"
117
+ )
118
+
119
+ // =============================================
120
+ // ADMIN GATING (negative tests)
121
+ // =============================================
122
+ await async_test(
123
+ "non-admin get_organization_api_keys is rejected (401)",
124
+ () => otherSdk.api.api_keys.get_organization_api_keys(),
125
+ { shouldError: true, onError: (e: any) => e.statusCode === 401 || /admin access only/i.test(e.message || '') }
126
+ )
127
+ await async_test(
128
+ "non-admin delete_organization_api_key is rejected (401)",
129
+ () => otherSdk.api.api_keys.delete_organization_api_key({ id: adminKey.id }),
130
+ { shouldError: true, onError: (e: any) => e.statusCode === 401 || /admin access only/i.test(e.message || '') }
131
+ )
132
+
133
+ // confirm the gating did not delete the key
134
+ const afterGating = await sdk.api.api_keys.get_organization_api_keys()
135
+ assert(
136
+ afterGating.apiKeys.some(k => k.id === adminKey.id),
137
+ "rejected non-admin delete still removed the key",
138
+ "rejected non-admin delete left the key intact"
139
+ )
140
+
141
+ // =============================================
142
+ // ADMIN DELETE another user's key
143
+ // =============================================
144
+ await async_test(
145
+ "admin delete_organization_api_key deletes another user's key",
146
+ () => sdk.api.api_keys.delete_organization_api_key({ id: otherKeyId! }),
147
+ { shouldError: false, onResult: () => true }
148
+ )
149
+
150
+ const afterDelete = await sdk.api.api_keys.get_organization_api_keys()
151
+ assert(
152
+ !afterDelete.apiKeys.some(k => k.id === otherKeyId),
153
+ "deleted key still present in org-wide list",
154
+ "deleted key no longer appears in the org-wide list"
155
+ )
156
+ otherKeyId = undefined // successfully deleted; nothing to clean up
157
+
158
+ // deleting a key in a different org (fabricated id) must be a no-op for our org
159
+ await async_test(
160
+ "admin delete of a non-existent/cross-org key id is a safe no-op",
161
+ () => sdk.api.api_keys.delete_organization_api_key({ id: '000000000000000000000000' }),
162
+ { shouldError: false, onResult: () => true }
163
+ )
164
+ const afterNoop = await sdk.api.api_keys.get_organization_api_keys()
165
+ assert(
166
+ afterNoop.apiKeys.some(k => k.id === adminKey.id),
167
+ "no-op delete removed an unrelated key",
168
+ "no-op cross-org delete left our org's keys intact"
169
+ )
170
+ } finally {
171
+ // cleanup: remove any keys we created (creator-only default delete)
172
+ for (const id of adminKeyIds) {
173
+ await sdk.api.api_keys.deleteOne(id).catch(() => {})
174
+ }
175
+ // the admin can clean up the throwaway user's key org-wide; fall through is harmless
176
+ if (otherKeyId) {
177
+ await sdk.api.api_keys.delete_organization_api_key({ id: otherKeyId }).catch(() => {})
178
+ }
179
+ if (otherUserId) {
180
+ await sdk.api.users.deleteOne(otherUserId).catch(() => {})
181
+ }
182
+ if (apiKeysRoleId) {
183
+ await sdk.api.role_based_access_permissions.deleteOne(apiKeysRoleId).catch(() => {})
184
+ }
185
+ }
186
+ }
187
+
188
+ // Allow running this test file independently
189
+ if (require.main === module) {
190
+ const sdk = new Session({ host })
191
+ const sdkNonAdmin = new Session({ host })
192
+
193
+ const runTests = async () => {
194
+ await setup_tests(sdk, sdkNonAdmin)
195
+ await organization_api_keys_tests({ sdk, sdkNonAdmin })
196
+ }
197
+
198
+ runTests()
199
+ .then(() => {
200
+ console.log("✅ Organization API keys test suite completed successfully")
201
+ process.exit(0)
202
+ })
203
+ .catch((error) => {
204
+ console.error("❌ Organization API keys test suite failed:", error)
205
+ process.exit(1)
206
+ })
207
+ }