@tellescope/sdk 1.255.17 → 1.255.19

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 (106) hide show
  1. package/lib/cjs/sdk.d.ts +7 -2
  2. package/lib/cjs/sdk.d.ts.map +1 -1
  3. package/lib/cjs/sdk.js +1 -0
  4. package/lib/cjs/sdk.js.map +1 -1
  5. package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.d.ts.map +1 -1
  6. package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.js +18 -8
  7. package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.js.map +1 -1
  8. package/lib/cjs/tests/api_tests/healthie_multi_integration.test.d.ts.map +1 -1
  9. package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js +203 -72
  10. package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js.map +1 -1
  11. package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.d.ts +6 -0
  12. package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.d.ts.map +1 -0
  13. package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.js +102 -0
  14. package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.js.map +1 -0
  15. package/lib/cjs/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -1
  16. package/lib/cjs/tests/api_tests/scoped_api_keys.test.js +215 -112
  17. package/lib/cjs/tests/api_tests/scoped_api_keys.test.js.map +1 -1
  18. package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -1
  19. package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +121 -78
  20. package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -1
  21. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts +6 -0
  22. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -0
  23. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +773 -0
  24. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -0
  25. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +17 -0
  26. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -0
  27. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +215 -0
  28. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -0
  29. package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts +6 -0
  30. package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts.map +1 -0
  31. package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js +1200 -0
  32. package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js.map +1 -0
  33. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts +6 -0
  34. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -0
  35. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +2330 -0
  36. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -0
  37. package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts +6 -0
  38. package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts.map +1 -0
  39. package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.js +204 -0
  40. package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.js.map +1 -0
  41. package/lib/cjs/tests/tests.d.ts.map +1 -1
  42. package/lib/cjs/tests/tests.js +211 -178
  43. package/lib/cjs/tests/tests.js.map +1 -1
  44. package/lib/esm/enduser.d.ts +1 -0
  45. package/lib/esm/enduser.d.ts.map +1 -1
  46. package/lib/esm/sdk.d.ts +10 -4
  47. package/lib/esm/sdk.d.ts.map +1 -1
  48. package/lib/esm/sdk.js +1 -0
  49. package/lib/esm/sdk.js.map +1 -1
  50. package/lib/esm/session.d.ts +1 -0
  51. package/lib/esm/session.d.ts.map +1 -1
  52. package/lib/esm/tests/api_tests/auto_merge_form_submission.test.d.ts.map +1 -1
  53. package/lib/esm/tests/api_tests/auto_merge_form_submission.test.js +18 -8
  54. package/lib/esm/tests/api_tests/auto_merge_form_submission.test.js.map +1 -1
  55. package/lib/esm/tests/api_tests/healthie_multi_integration.test.d.ts.map +1 -1
  56. package/lib/esm/tests/api_tests/healthie_multi_integration.test.js +205 -74
  57. package/lib/esm/tests/api_tests/healthie_multi_integration.test.js.map +1 -1
  58. package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.d.ts +6 -0
  59. package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.d.ts.map +1 -0
  60. package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.js +98 -0
  61. package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.js.map +1 -0
  62. package/lib/esm/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -1
  63. package/lib/esm/tests/api_tests/scoped_api_keys.test.js +215 -112
  64. package/lib/esm/tests/api_tests/scoped_api_keys.test.js.map +1 -1
  65. package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -1
  66. package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +121 -78
  67. package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -1
  68. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts +6 -0
  69. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -0
  70. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +766 -0
  71. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -0
  72. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +17 -0
  73. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -0
  74. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +211 -0
  75. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -0
  76. package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts +6 -0
  77. package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts.map +1 -0
  78. package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js +1193 -0
  79. package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js.map +1 -0
  80. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts +6 -0
  81. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -0
  82. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +2323 -0
  83. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -0
  84. package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts +6 -0
  85. package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts.map +1 -0
  86. package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.js +200 -0
  87. package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.js.map +1 -0
  88. package/lib/esm/tests/tests.d.ts.map +1 -1
  89. package/lib/esm/tests/tests.js +211 -178
  90. package/lib/esm/tests/tests.js.map +1 -1
  91. package/lib/tsconfig.tsbuildinfo +1 -1
  92. package/package.json +10 -10
  93. package/src/sdk.ts +4 -0
  94. package/src/tests/api_tests/auto_merge_form_submission.test.ts +13 -4
  95. package/src/tests/api_tests/healthie_multi_integration.test.ts +103 -5
  96. package/src/tests/api_tests/phone_calls_conference_hold.test.ts +62 -0
  97. package/src/tests/api_tests/scoped_api_keys.test.ts +87 -0
  98. package/src/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.ts +38 -4
  99. package/src/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.ts +506 -0
  100. package/src/tests/api_tests/security/enduser-ai-summary-trust-gate.test.ts +125 -0
  101. package/src/tests/api_tests/security/formsort_webhook_auth_field_lock.test.ts +753 -0
  102. package/src/tests/api_tests/security/public_endpoint_auth_field_lock.test.ts +1707 -0
  103. package/src/tests/api_tests/tickets_bulk_assign_care_team.test.ts +132 -0
  104. package/src/tests/tests.ts +20 -1
  105. package/test_generated.pdf +0 -0
  106. package/.env +0 -13
package/src/sdk.ts CHANGED
@@ -716,6 +716,9 @@ type Queries = { [K in keyof ClientModelForName]: APIQuery<K> } & {
716
716
  remove_conference_attendees: (args: extractFields<CustomActions['phone_calls']['remove_conference_attendees']['parameters']>) => (
717
717
  Promise<extractFields<CustomActions['phone_calls']['remove_conference_attendees']['returns']>>
718
718
  ),
719
+ modify_conference_attendee_status: (args: extractFields<CustomActions['phone_calls']['modify_conference_attendee_status']['parameters']>) => (
720
+ Promise<extractFields<CustomActions['phone_calls']['modify_conference_attendee_status']['returns']>>
721
+ ),
719
722
  end_conference: (args: extractFields<CustomActions['phone_calls']['end_conference']['parameters']>) => (
720
723
  Promise<extractFields<CustomActions['phone_calls']['end_conference']['returns']>>
721
724
  ),
@@ -1163,6 +1166,7 @@ export class Session extends SessionManager {
1163
1166
  queries.phone_calls.cold_transfer = a => this._POST(`/v1${schema.phone_calls.customActions.cold_transfer.path}`, a)
1164
1167
  queries.phone_calls.add_conference_attendees = a => this._POST(`/v1${schema.phone_calls.customActions.add_conference_attendees.path}`, a)
1165
1168
  queries.phone_calls.remove_conference_attendees = a => this._POST(`/v1${schema.phone_calls.customActions.remove_conference_attendees.path}`, a)
1169
+ queries.phone_calls.modify_conference_attendee_status = a => this._POST(`/v1${schema.phone_calls.customActions.modify_conference_attendee_status.path}`, a)
1166
1170
  queries.phone_calls.end_conference = a => this._POST(`/v1${schema.phone_calls.customActions.end_conference.path}`, a)
1167
1171
  queries.phone_calls.cancel_recording = a => this._POST(`/v1${schema.phone_calls.customActions.cancel_recording.path}`, a)
1168
1172
  queries.phone_calls.delete_recordings = a => this._POST(`/v1${schema.phone_calls.customActions.delete_recordings.path}`, a)
@@ -547,9 +547,9 @@ export const auto_merge_form_submission_tests = async ({ sdk, sdkNonAdmin }: { s
547
547
  { expectedResult: true }
548
548
  )
549
549
 
550
- // Test 12: Intake fields update destination enduser directly
550
+ // Test 12: Intake fields update destination enduser directly — except the authentication channels
551
551
  await async_test(
552
- "Auto-merge: Intake fields update destination enduser directly",
552
+ "Auto-merge: Intake fields update destination enduser directly (email/phone locked)",
553
553
  async () => {
554
554
  const testEmail = `automerge.intake.${Date.now()}@test.com`
555
555
  const destination = await sdk.api.endusers.createOne({
@@ -579,10 +579,19 @@ export const auto_merge_form_submission_tests = async ({ sdk, sdkNonAdmin }: { s
579
579
  await sdk.api.forms.deleteOne(form.id)
580
580
  await sdk.api.endusers.deleteOne(destination.id)
581
581
 
582
- // Intake fields should have been applied to the destination enduser
582
+ // Non-auth intake fields are still applied to the destination — dateOfBirth landing here is
583
+ // also what proves the intake write ran at all, so the negative assertions below aren't vacuous.
583
584
  return sourceDeleted
584
- && updatedDestination.phone === newPhone
585
585
  && updatedDestination.dateOfBirth === newDOB
586
+ // ...but email and phone are NOT applied. The merge attaches this response to a contact that
587
+ // existed before the submission, which sets FormResponse.authFieldIntakeDisabled, and these two
588
+ // are authentication channels (password reset resolves by email; OTPs are delivered to phone).
589
+ // Filling a BLANK phone is deliberately blocked too — contact-less patients are the primary
590
+ // takeover target, so "fill only if empty" would not close the hole. Staff update these fields.
591
+ // Previously this test asserted `phone === newPhone`; that behaviour was the vulnerability.
592
+ // See api_tests/security/public_endpoint_auth_field_lock.test.ts (G26).
593
+ && !updatedDestination.phone
594
+ && updatedDestination.email === testEmail
586
595
  },
587
596
  { expectedResult: true }
588
597
  )
@@ -8,9 +8,9 @@ import {
8
8
  log_header,
9
9
  wait,
10
10
  } from "@tellescope/testing"
11
- import { evaluate_conditional_logic_for_enduser_fields } from "@tellescope/utilities"
11
+ import { evaluate_conditional_logic_for_enduser_fields, objects_equivalent } from "@tellescope/utilities"
12
12
  import { setup_tests } from "../setup"
13
- import { BUILT_INS_FOR_SET_FIELDS, HEALTHIE_TITLE } from "@tellescope/constants"
13
+ import { BUILT_INS_FOR_SET_FIELDS, ENDUSER_FIELD_TYPES, EnduserField, HEALTHIE_TITLE } from "@tellescope/constants"
14
14
 
15
15
  const host = process.env.API_URL || 'http://localhost:8080' as const
16
16
 
@@ -27,6 +27,12 @@ export const healthie_multi_integration_tests = async ({ sdk, sdkNonAdmin } : {
27
27
  let enduserId = ''
28
28
  let formsortEnduserId = ''
29
29
  let formId = ''
30
+ let hiddenFormId = ''
31
+ let multipleChoiceFormId = ''
32
+ let hiddenIntakeEnduserId = ''
33
+ let multipleChoiceIntakeEnduserId = ''
34
+ let numberIntakeEnduserId = ''
35
+ const formResponseIds: string[] = []
30
36
 
31
37
  try {
32
38
  // ── Set Fields support ────────────────────────────────────────────────────────────────────
@@ -236,6 +242,90 @@ export const healthie_multi_integration_tests = async ({ sdk, sdkNonAdmin } : {
236
242
  { onResult: e => e.healthieIntegrationId === 'somewhere-else' },
237
243
  )
238
244
 
245
+ // ── Form intake field: "Update Patient Profile" writes the top-level enduser column ──
246
+ const submitIntakeResponse = async (enduserId: string, formId: string, response: any) => {
247
+ const { accessCode, response: prepared } = await sdk.api.form_responses.prepare_form_response({ enduserId, formId })
248
+ formResponseIds.push(prepared.id)
249
+ await sdk.api.form_responses.submit_form_response({ accessCode, responses: [response] })
250
+ }
251
+
252
+ const hiddenForm = await sdk.api.forms.createOne({ title: "Multi Healthie Intake (Hidden Value)" })
253
+ hiddenFormId = hiddenForm.id
254
+ const hiddenField = await sdk.api.form_fields.createOne({
255
+ formId: hiddenForm.id,
256
+ title: "Healthie Integration",
257
+ type: 'Hidden Value',
258
+ intakeField: 'healthieIntegrationId',
259
+ previousFields: [{ type: 'root', info: {} }],
260
+ })
261
+
262
+ const hiddenIntakeEnduser = await sdk.api.endusers.createOne({ email: 'multi-healthie-intake-hidden@tellescope.com' })
263
+ hiddenIntakeEnduserId = hiddenIntakeEnduser.id
264
+
265
+ await submitIntakeResponse(hiddenIntakeEnduserId, hiddenForm.id, {
266
+ fieldId: hiddenField.id, fieldTitle: hiddenField.title, answer: { type: 'Hidden Value', value: TAG },
267
+ })
268
+ await async_test(
269
+ "Hidden Value intake field sets healthieIntegrationId",
270
+ () => sdk.api.endusers.getOne(hiddenIntakeEnduserId),
271
+ { onResult: e => e.healthieIntegrationId === TAG },
272
+ )
273
+
274
+ // Multiple Choice answers arrive as arrays — the stored value must be the collapsed scalar,
275
+ // otherwise stringValidator100 rejects the update and takes the whole submission's intake down
276
+ const multipleChoiceForm = await sdk.api.forms.createOne({ title: "Multi Healthie Intake (Multiple Choice)" })
277
+ multipleChoiceFormId = multipleChoiceForm.id
278
+ const multipleChoiceField = await sdk.api.form_fields.createOne({
279
+ formId: multipleChoiceForm.id,
280
+ title: "Which clinic?",
281
+ type: 'multiple_choice',
282
+ intakeField: 'healthieIntegrationId',
283
+ options: { choices: [TAG, 'other-clinic'], radio: true },
284
+ previousFields: [{ type: 'root', info: {} }],
285
+ })
286
+
287
+ const multipleChoiceIntakeEnduser = await sdk.api.endusers.createOne({ email: 'multi-healthie-intake-mc@tellescope.com' })
288
+ multipleChoiceIntakeEnduserId = multipleChoiceIntakeEnduser.id
289
+
290
+ await submitIntakeResponse(multipleChoiceIntakeEnduserId, multipleChoiceForm.id, {
291
+ fieldId: multipleChoiceField.id, fieldTitle: multipleChoiceField.title, answer: { type: 'multiple_choice', value: [TAG] },
292
+ })
293
+ await async_test(
294
+ "Multiple Choice intake field stores healthieIntegrationId as a scalar",
295
+ () => sdk.api.endusers.getOne(multipleChoiceIntakeEnduserId),
296
+ { onResult: e => e.healthieIntegrationId === TAG },
297
+ )
298
+
299
+ // Allowed form field types. The form_fields model validates intakeField as a free string (isFormField,
300
+ // which checks ENDUSER_FIELD_TYPES, is not wired into its schema), so this is enforced in the form editor
301
+ // rather than by the API — same as every other intake field. Assert the shared constant the editor reads.
302
+ assert(
303
+ objects_equivalent(
304
+ ENDUSER_FIELD_TYPES['healthieIntegrationId' as EnduserField],
305
+ ['Hidden Value', 'Dropdown', 'multiple_choice', 'string'],
306
+ ),
307
+ 'ENDUSER_FIELD_TYPES error', 'healthieIntegrationId is limited to Hidden Value, Dropdown, Multiple Choice, and Text',
308
+ )
309
+
310
+ // An incompatible type is accepted by the API but must never write a non-string to the enduser column
311
+ const numberField = await sdk.api.form_fields.createOne({
312
+ formId: multipleChoiceForm.id,
313
+ title: "Bad Type",
314
+ type: 'number',
315
+ intakeField: 'healthieIntegrationId',
316
+ previousFields: [{ type: 'after', info: { fieldId: multipleChoiceField.id } }],
317
+ })
318
+ const numberIntakeEnduser = await sdk.api.endusers.createOne({ email: 'multi-healthie-intake-number@tellescope.com' })
319
+ numberIntakeEnduserId = numberIntakeEnduser.id
320
+ await submitIntakeResponse(numberIntakeEnduserId, multipleChoiceForm.id, {
321
+ fieldId: numberField.id, fieldTitle: numberField.title, answer: { type: 'number', value: 5 },
322
+ })
323
+ await async_test(
324
+ "non-string answer for the healthieIntegrationId intake field is ignored",
325
+ () => sdk.api.endusers.getOne(numberIntakeEnduserId),
326
+ { onResult: e => !e.healthieIntegrationId },
327
+ )
328
+
239
329
  // ── Removing the primary must not affect the tagged integration; org list recomputes on delete ──
240
330
  await sdk.api.integrations.deleteOne(primaryIntegrationId)
241
331
  primaryIntegrationId = ''
@@ -258,9 +348,17 @@ export const healthie_multi_integration_tests = async ({ sdk, sdkNonAdmin } : {
258
348
  if (!id) continue
259
349
  await sdk.api.integrations.deleteOne(id).catch(console.error)
260
350
  }
261
- if (enduserId) await sdk.api.endusers.deleteOne(enduserId).catch(console.error)
262
- if (formsortEnduserId) await sdk.api.endusers.deleteOne(formsortEnduserId).catch(console.error)
263
- if (formId) await sdk.api.forms.deleteOne(formId).catch(console.error)
351
+ for (const id of formResponseIds) {
352
+ await sdk.api.form_responses.deleteOne(id).catch(console.error)
353
+ }
354
+ for (const id of [enduserId, formsortEnduserId, hiddenIntakeEnduserId, multipleChoiceIntakeEnduserId, numberIntakeEnduserId]) {
355
+ if (!id) continue
356
+ await sdk.api.endusers.deleteOne(id).catch(console.error)
357
+ }
358
+ for (const id of [formId, hiddenFormId, multipleChoiceFormId]) {
359
+ if (!id) continue
360
+ await sdk.api.forms.deleteOne(id).catch(console.error)
361
+ }
264
362
  }
265
363
  }
266
364
 
@@ -0,0 +1,62 @@
1
+ require('source-map-support').install();
2
+
3
+ import { Session } from "../../sdk"
4
+ import {
5
+ async_test,
6
+ handleAnyError,
7
+ log_header,
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
+ // Validation-path tests for the modify-conference-attendee-status endpoint. All assertions
14
+ // below hit validations that run before any Twilio access, so no Twilio config is needed.
15
+ // conferenceId is not settable via the public API (only set internally by conference
16
+ // webhooks), so the Twilio hold/unhold branches are covered by manual E2E testing only.
17
+ export const phone_calls_conference_hold_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
18
+ log_header("Phone Calls Conference Hold Tests")
19
+
20
+ await async_test(
21
+ "modify_conference_attendee_status: no status fields rejected",
22
+ () => sdk.api.phone_calls.modify_conference_attendee_status({ conferenceId: 'CAconferenceholdtest0000000000001' }),
23
+ { shouldError: true, onError: (e: any) => e.message === "At least one status field (e.g. hold) must be provided" }
24
+ )
25
+ await async_test(
26
+ "modify_conference_attendee_status: bogus conferenceId rejected",
27
+ () => sdk.api.phone_calls.modify_conference_attendee_status({ conferenceId: 'CAdoesnotexist0000000000000000000', hold: true }),
28
+ { shouldError: true, onError: (e: any) => e.message === "Call not found" }
29
+ )
30
+ await async_test(
31
+ "modify_conference_attendee_status: hold false also validated against record access",
32
+ () => sdk.api.phone_calls.modify_conference_attendee_status({ conferenceId: 'CAdoesnotexist0000000000000000000', hold: false }),
33
+ { shouldError: true, onError: (e: any) => e.message === "Call not found" }
34
+ )
35
+ await async_test(
36
+ "modify_conference_attendee_status: non-admin cannot hold an inaccessible conference",
37
+ () => sdkNonAdmin.api.phone_calls.modify_conference_attendee_status({ conferenceId: 'CAdoesnotexist0000000000000000000', hold: true }),
38
+ handleAnyError
39
+ )
40
+ }
41
+
42
+ // Allow running this test file independently
43
+ if (require.main === module) {
44
+ console.log(`🌐 Using API URL: ${host}`)
45
+ const sdk = new Session({ host })
46
+ const sdkNonAdmin = new Session({ host })
47
+
48
+ const runTests = async () => {
49
+ await setup_tests(sdk, sdkNonAdmin)
50
+ await phone_calls_conference_hold_tests({ sdk, sdkNonAdmin })
51
+ }
52
+
53
+ runTests()
54
+ .then(() => {
55
+ console.log("✅ Phone calls conference hold test suite completed successfully")
56
+ process.exit(0)
57
+ })
58
+ .catch((error) => {
59
+ console.error("❌ Phone calls conference hold test suite failed:", error)
60
+ process.exit(1)
61
+ })
62
+ }
@@ -35,6 +35,7 @@ export const scoped_api_keys_tests = async ({ sdk, sdkNonAdmin } : { sdk: Sessio
35
35
 
36
36
  const keyIds: string[] = []
37
37
  const formResponseIds: string[] = []
38
+ const fileIds: string[] = []
38
39
  let throwawayUserId: string | undefined
39
40
  let throwawayRoleId: string | undefined
40
41
 
@@ -77,6 +78,11 @@ export const scoped_api_keys_tests = async ({ sdk, sdkNonAdmin } : { sdk: Sessio
77
78
  }),
78
79
  { shouldError: true, onError: (e: any) => e.response?.status === 401 },
79
80
  )
81
+ await async_test(
82
+ "scoped key cannot create API keys (escalation guard)",
83
+ () => axios.post(`${host}/v1/api-key`, {}, { headers: { Authorization: `API_KEY ${scoped.key}` } }),
84
+ { shouldError: true, onError: (e: any) => e.response?.status === 401 },
85
+ )
80
86
 
81
87
  // =============================================
82
88
  // (c) Scoped key IS accepted by the form-ingestion webhook
@@ -101,6 +107,44 @@ export const scoped_api_keys_tests = async ({ sdk, sdkNonAdmin } : { sdk: Sessio
101
107
  )
102
108
  if (pathResult.data?.formResponseId) formResponseIds.push(pathResult.data.formResponseId)
103
109
 
110
+ // =============================================
111
+ // (c2) Scoped key CAN run the file-upload flow (prepare + confirm) — needed for intake forms with files
112
+ // =============================================
113
+ const scopedSession = new Session({ host, apiKey: scoped.key })
114
+ const { file: uploadedFile } = await scopedSession.api.files.prepare_file_upload({
115
+ name: 'scoped-intake-file.txt', size: 12, type: 'text/plain',
116
+ })
117
+ assert(
118
+ !!uploadedFile?.id,
119
+ "scoped key prepare_file_upload returned no file",
120
+ "scoped key can call POST /prepare-file-upload",
121
+ )
122
+ if (uploadedFile?.id) fileIds.push(uploadedFile.id)
123
+
124
+ await async_test(
125
+ "scoped key can call POST /files/confirm-upload",
126
+ () => scopedSession.api.files.confirm_file_upload({ id: uploadedFile.id }),
127
+ { onResult: () => true },
128
+ )
129
+
130
+ // ...but is blocked from EVERY other endpoint in the files domain — proving the two allowed paths are the
131
+ // ONLY files access, and that the '/files/confirm-upload' allow-prefix does not open the '/files/*' namespace.
132
+ const blockedFileEndpoints: [string, () => Promise<any>][] = [
133
+ ['GET /v1/files (readMany)', () => axios.get(`${host}/v1/files`, { headers: { Authorization: `API_KEY ${scoped.key}` } })],
134
+ ['GET /v1/file/:id (read one)', () => axios.get(`${host}/v1/file/000000000000000000000000`, { headers: { Authorization: `API_KEY ${scoped.key}` } })],
135
+ ['DELETE /v1/file/:id (destructive)', () => axios.delete(`${host}/v1/file/000000000000000000000000`, { headers: { Authorization: `API_KEY ${scoped.key}` } })],
136
+ ['POST /v1/files/send-fax (/files/* sibling)', () => axios.post(`${host}/v1/files/send-fax`, {}, { headers: { Authorization: `API_KEY ${scoped.key}` } })],
137
+ ['POST /v1/files/ocr (/files/* sibling)', () => axios.post(`${host}/v1/files/ocr`, {}, { headers: { Authorization: `API_KEY ${scoped.key}` } })],
138
+ ['POST /v1/files/push (/files/* sibling)', () => axios.post(`${host}/v1/files/push`, {}, { headers: { Authorization: `API_KEY ${scoped.key}` } })],
139
+ ]
140
+ for (const [label, call] of blockedFileEndpoints) {
141
+ await async_test(
142
+ `scoped key blocked on ${label}`,
143
+ call,
144
+ { shouldError: true, onError: (e: any) => e.response?.status === 401 || e.response?.status === 403 },
145
+ )
146
+ }
147
+
104
148
  // =============================================
105
149
  // (d) Non-admin cannot create a scoped key (throwaway user, never mutate existing roles)
106
150
  // =============================================
@@ -185,6 +229,46 @@ export const scoped_api_keys_tests = async ({ sdk, sdkNonAdmin } : { sdk: Sessio
185
229
  "get_organization_api_keys still never returns key/hashedKey",
186
230
  )
187
231
 
232
+ // =============================================
233
+ // (g) Legacy non-/v1 funnel (app.js requireLoginAndAccess → is_logged_in_API_key with NO requestPath).
234
+ // Proves scoped keys fail closed on every non-/v1 caller (also stands in for the Medplum webhook path),
235
+ // and that unscoped keys still authenticate the legacy funnel after this work.
236
+ // NOTE: the legacy middleware reads the key from ?apiKey= / body only (never the Authorization header).
237
+ // =============================================
238
+ await async_test(
239
+ "unscoped key authenticates a legacy non-/v1 route",
240
+ () => axios.get(`${host}/API-keys-for-user?apiKey=${encodeURIComponent(unscopedKeyValue)}`),
241
+ { onResult: (r: any) => r.status === 200 },
242
+ )
243
+ await async_test(
244
+ "scoped key rejected on a legacy non-/v1 route (fail closed, no requestPath)",
245
+ () => axios.get(`${host}/API-keys-for-user?apiKey=${encodeURIComponent(scoped.key)}`),
246
+ { shouldError: true, onError: (e: any) => e.response?.status === 401 },
247
+ )
248
+
249
+ // =============================================
250
+ // (h) Data-egress adjacency: the scope's allowed paths must NOT leak to the file DOWNLOAD endpoint.
251
+ // =============================================
252
+ await async_test(
253
+ "scoped key cannot call file_download_URL (no PHI egress)",
254
+ () => axios.get(`${host}/v1/file-download-URL?secureName=nonexistent`, { headers: { Authorization: `API_KEY ${scoped.key}` } }),
255
+ { shouldError: true, onError: (e: any) => e.response?.status === 401 || e.response?.status === 403 },
256
+ )
257
+
258
+ // =============================================
259
+ // (i) Transport independence: the scope gate applies whether the key is in the header or the ?apiKey= query.
260
+ // =============================================
261
+ await async_test(
262
+ "scoped key via ?apiKey= query param still rejected on a disallowed /v1 endpoint",
263
+ () => axios.get(`${host}/v1/endusers?apiKey=${encodeURIComponent(scoped.key)}`),
264
+ { shouldError: true, onError: (e: any) => e.response?.status === 401 || e.response?.status === 403 },
265
+ )
266
+ await async_test(
267
+ "unscoped key via ?apiKey= query param still authenticates /v1 (regression)",
268
+ () => axios.get(`${host}/v1/test-authenticated?apiKey=${encodeURIComponent(unscopedKeyValue)}`),
269
+ { onResult: (r: any) => r.status === 200 },
270
+ )
271
+
188
272
  // =============================================
189
273
  // Validation: unknown scope values are rejected
190
274
  // =============================================
@@ -200,6 +284,9 @@ export const scoped_api_keys_tests = async ({ sdk, sdkNonAdmin } : { sdk: Sessio
200
284
  for (const id of formResponseIds) {
201
285
  await sdk.api.form_responses.deleteOne(id).catch(() => {})
202
286
  }
287
+ for (const id of fileIds) {
288
+ await sdk.api.files.deleteOne(id).catch(() => {})
289
+ }
203
290
  try {
204
291
  const enduser = await sdk.api.endusers.getOne({ email: TEST_EMAIL })
205
292
  await sdk.api.endusers.deleteOne(enduser.id)
@@ -43,8 +43,15 @@ const restrictedEnduserUpdates = (staffId: string, mongoId: string): Record<stri
43
43
  })
44
44
 
45
45
  // Every form_responses field marked enduserUpdatesDisabled in schema.ts.
46
- const restrictedFormResponseUpdates = (staffId: string): Record<string, any> => ({
46
+ // otherEnduserId must be a REAL enduser other than the one submitting the update: enduserId's
47
+ // foreign key is existence-checked, so a nonexistent id would be rejected as a bad dependency
48
+ // rather than by the write restriction under test.
49
+ const restrictedFormResponseUpdates = (staffId: string, otherEnduserId: string): Record<string, any> => ({
47
50
  markedAsSubmitted: true,
51
+ // repointing a response at another patient redirects the intake write in
52
+ // save_enduser_updates_for_form_response, which is how email/phone (authentication channels)
53
+ // reach an enduser record — see api_tests/security/public_endpoint_auth_field_lock.test.ts
54
+ enduserId: otherEnduserId,
48
55
  submittedBy: staffId,
49
56
  submittedAt: new Date("2024-01-01T00:00:00Z"),
50
57
  submittedByIsPlaceholder: true,
@@ -84,6 +91,7 @@ export const enduser_write_restrictions_tests = async ({ sdk } : { sdk: Session,
84
91
  log_header("F-0106/F-0110: enduser write restrictions (enduserUpdatesDisabled)")
85
92
 
86
93
  let enduserId: string | undefined
94
+ let otherEnduserId: string | undefined
87
95
  let formId: string | undefined
88
96
  let formResponseId: string | undefined
89
97
 
@@ -92,6 +100,12 @@ export const enduser_write_restrictions_tests = async ({ sdk } : { sdk: Session,
92
100
  const enduserEmail = `f0106-enduser-${Date.now()}@example.com`
93
101
  const enduser = await sdk.api.endusers.createOne({ email: enduserEmail, fname: 'F0106', lname: 'Restricted' })
94
102
  enduserId = enduser.id
103
+
104
+ // The would-be victim of an enduserId repoint, and the destination for the staff positive control.
105
+ const otherEnduser = await sdk.api.endusers.createOne({
106
+ email: `f0106-other-${Date.now()}@example.com`, fname: 'F0106', lname: 'Other',
107
+ })
108
+ otherEnduserId = otherEnduser.id
95
109
  await sdk.api.endusers.set_password({ id: enduser.id, password: ENDUSER_PASSWORD })
96
110
 
97
111
  const enduserSDK = new EnduserSession({ host, businessId })
@@ -123,7 +137,7 @@ export const enduser_write_restrictions_tests = async ({ sdk } : { sdk: Session,
123
137
  )
124
138
 
125
139
  // ---- F-0110: enduser self-update of restricted form_responses fields must 400 ----
126
- for (const [field, value] of Object.entries(restrictedFormResponseUpdates(sdk.userInfo.id))) {
140
+ for (const [field, value] of Object.entries(restrictedFormResponseUpdates(sdk.userInfo.id, otherEnduser.id))) {
127
141
  await async_test(
128
142
  `F-0110: enduser cannot self-update form_responses.${field}`,
129
143
  () => enduserSDK.api.form_responses.updateOne(formResponse.id, { [field]: value } as any),
@@ -132,9 +146,13 @@ export const enduser_write_restrictions_tests = async ({ sdk } : { sdk: Session,
132
146
  }
133
147
 
134
148
  await async_test(
135
- 'F-0110: rejected writes did not persist (markedAsSubmitted, procedureCodes, submittedBy)',
149
+ 'F-0110: rejected writes did not persist (markedAsSubmitted, procedureCodes, submittedBy, enduserId)',
136
150
  () => sdk.api.form_responses.getOne(formResponse.id),
137
- { onResult: fr => !fr.markedAsSubmitted && !fr.procedureCodes?.length && fr.submittedBy === undefined },
151
+ { onResult: fr => (
152
+ !fr.markedAsSubmitted && !fr.procedureCodes?.length && fr.submittedBy === undefined
153
+ // still points at its own patient: a successful repoint is what redirects the intake write
154
+ && fr.enduserId === enduser.id
155
+ ) },
138
156
  )
139
157
 
140
158
  // ---- Positive controls: intended enduser self-updates still work ----
@@ -178,6 +196,19 @@ export const enduser_write_restrictions_tests = async ({ sdk } : { sdk: Session,
178
196
  && !!fr.tags?.includes('staff-set-tag')
179
197
  ) },
180
198
  )
199
+
200
+ // enduserId is enduserUpdatesDisabled rather than updatesDisabled specifically so this keeps
201
+ // working: staff sometimes need to move a response to the correct profile to fix a mistake.
202
+ await async_test(
203
+ 'staff can still repoint form_responses.enduserId to another patient',
204
+ () => sdk.api.form_responses.updateOne(formResponse.id, { enduserId: otherEnduser.id }),
205
+ { onResult: fr => fr.enduserId === otherEnduser.id },
206
+ )
207
+ await async_test(
208
+ 'staff can repoint form_responses.enduserId back',
209
+ () => sdk.api.form_responses.updateOne(formResponse.id, { enduserId: enduser.id }),
210
+ { onResult: fr => fr.enduserId === enduser.id },
211
+ )
181
212
  } finally {
182
213
  if (formResponseId) {
183
214
  try { await sdk.api.form_responses.deleteOne(formResponseId) } catch {}
@@ -188,6 +219,9 @@ export const enduser_write_restrictions_tests = async ({ sdk } : { sdk: Session,
188
219
  if (enduserId) {
189
220
  try { await sdk.api.endusers.deleteOne(enduserId) } catch {}
190
221
  }
222
+ if (otherEnduserId) {
223
+ try { await sdk.api.endusers.deleteOne(otherEnduserId) } catch {}
224
+ }
191
225
  }
192
226
  }
193
227