@tellescope/sdk 1.255.18 → 1.256.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 (165) 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/custom_dashboards.test.d.ts.map +1 -1
  9. package/lib/cjs/tests/api_tests/custom_dashboards.test.js +99 -8
  10. package/lib/cjs/tests/api_tests/custom_dashboards.test.js.map +1 -1
  11. package/lib/cjs/tests/api_tests/default_summary_journey.test.d.ts.map +1 -0
  12. package/lib/cjs/tests/api_tests/default_summary_journey.test.js.map +1 -0
  13. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.d.ts +6 -0
  14. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.d.ts.map +1 -0
  15. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.js +278 -0
  16. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.js.map +1 -0
  17. package/lib/cjs/tests/api_tests/healthie_multi_integration.test.d.ts.map +1 -1
  18. package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js +203 -72
  19. package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js.map +1 -1
  20. package/lib/cjs/tests/api_tests/openloop_webhooks.test.d.ts.map +1 -1
  21. package/lib/cjs/tests/api_tests/openloop_webhooks.test.js +212 -33
  22. package/lib/cjs/tests/api_tests/openloop_webhooks.test.js.map +1 -1
  23. package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.d.ts.map +1 -1
  24. package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.js +85 -9
  25. package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.js.map +1 -1
  26. package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.d.ts +6 -0
  27. package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.d.ts.map +1 -0
  28. package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.js +102 -0
  29. package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.js.map +1 -0
  30. package/lib/cjs/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -1
  31. package/lib/cjs/tests/api_tests/scoped_api_keys.test.js +84 -81
  32. package/lib/cjs/tests/api_tests/scoped_api_keys.test.js.map +1 -1
  33. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts +2 -0
  34. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts.map +1 -0
  35. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js +242 -0
  36. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js.map +1 -0
  37. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts +29 -0
  38. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts.map +1 -0
  39. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js +443 -0
  40. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js.map +1 -0
  41. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts +35 -0
  42. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts.map +1 -0
  43. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js +231 -0
  44. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js.map +1 -0
  45. package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -1
  46. package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +121 -78
  47. package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -1
  48. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts +6 -0
  49. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -0
  50. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +1003 -0
  51. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -0
  52. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +17 -0
  53. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -0
  54. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +215 -0
  55. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -0
  56. package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts +6 -0
  57. package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts.map +1 -0
  58. package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js +1200 -0
  59. package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js.map +1 -0
  60. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts +6 -0
  61. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -0
  62. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +2330 -0
  63. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -0
  64. package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts +6 -0
  65. package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts.map +1 -0
  66. package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.js +204 -0
  67. package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.js.map +1 -0
  68. package/lib/cjs/tests/tests.d.ts.map +1 -1
  69. package/lib/cjs/tests/tests.js +489 -255
  70. package/lib/cjs/tests/tests.js.map +1 -1
  71. package/lib/esm/enduser.d.ts +0 -1
  72. package/lib/esm/enduser.d.ts.map +1 -1
  73. package/lib/esm/sdk.d.ts +9 -5
  74. package/lib/esm/sdk.d.ts.map +1 -1
  75. package/lib/esm/sdk.js +1 -0
  76. package/lib/esm/sdk.js.map +1 -1
  77. package/lib/esm/session.d.ts +0 -1
  78. package/lib/esm/session.d.ts.map +1 -1
  79. package/lib/esm/tests/api_tests/auto_merge_form_submission.test.d.ts.map +1 -1
  80. package/lib/esm/tests/api_tests/auto_merge_form_submission.test.js +18 -8
  81. package/lib/esm/tests/api_tests/auto_merge_form_submission.test.js.map +1 -1
  82. package/lib/esm/tests/api_tests/custom_dashboards.test.d.ts.map +1 -1
  83. package/lib/esm/tests/api_tests/custom_dashboards.test.js +99 -8
  84. package/lib/esm/tests/api_tests/custom_dashboards.test.js.map +1 -1
  85. package/lib/esm/tests/api_tests/default_summary_journey.test.d.ts.map +1 -0
  86. package/lib/esm/tests/api_tests/default_summary_journey.test.js.map +1 -0
  87. package/lib/esm/tests/api_tests/file_download_unicode_names.test.d.ts +6 -0
  88. package/lib/esm/tests/api_tests/file_download_unicode_names.test.d.ts.map +1 -0
  89. package/lib/esm/tests/api_tests/file_download_unicode_names.test.js +248 -0
  90. package/lib/esm/tests/api_tests/file_download_unicode_names.test.js.map +1 -0
  91. package/lib/esm/tests/api_tests/healthie_multi_integration.test.d.ts.map +1 -1
  92. package/lib/esm/tests/api_tests/healthie_multi_integration.test.js +205 -74
  93. package/lib/esm/tests/api_tests/healthie_multi_integration.test.js.map +1 -1
  94. package/lib/esm/tests/api_tests/openloop_webhooks.test.d.ts.map +1 -1
  95. package/lib/esm/tests/api_tests/openloop_webhooks.test.js +213 -34
  96. package/lib/esm/tests/api_tests/openloop_webhooks.test.js.map +1 -1
  97. package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.d.ts.map +1 -1
  98. package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.js +85 -9
  99. package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.js.map +1 -1
  100. package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.d.ts +6 -0
  101. package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.d.ts.map +1 -0
  102. package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.js +98 -0
  103. package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.js.map +1 -0
  104. package/lib/esm/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -1
  105. package/lib/esm/tests/api_tests/scoped_api_keys.test.js +84 -81
  106. package/lib/esm/tests/api_tests/scoped_api_keys.test.js.map +1 -1
  107. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts +2 -0
  108. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts.map +1 -0
  109. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js +238 -0
  110. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js.map +1 -0
  111. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts +29 -0
  112. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts.map +1 -0
  113. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js +436 -0
  114. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js.map +1 -0
  115. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts +35 -0
  116. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts.map +1 -0
  117. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js +224 -0
  118. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js.map +1 -0
  119. package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -1
  120. package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +121 -78
  121. package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -1
  122. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts +6 -0
  123. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -0
  124. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +996 -0
  125. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -0
  126. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +17 -0
  127. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -0
  128. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +211 -0
  129. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -0
  130. package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts +6 -0
  131. package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts.map +1 -0
  132. package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js +1193 -0
  133. package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js.map +1 -0
  134. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts +6 -0
  135. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -0
  136. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +2323 -0
  137. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -0
  138. package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts +6 -0
  139. package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts.map +1 -0
  140. package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.js +200 -0
  141. package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.js.map +1 -0
  142. package/lib/esm/tests/tests.d.ts.map +1 -1
  143. package/lib/esm/tests/tests.js +489 -255
  144. package/lib/esm/tests/tests.js.map +1 -1
  145. package/lib/tsconfig.tsbuildinfo +1 -1
  146. package/package.json +10 -10
  147. package/src/sdk.ts +4 -0
  148. package/src/tests/api_tests/auto_merge_form_submission.test.ts +13 -4
  149. package/src/tests/api_tests/custom_dashboards.test.ts +92 -0
  150. package/src/tests/api_tests/file_download_unicode_names.test.ts +223 -0
  151. package/src/tests/api_tests/healthie_multi_integration.test.ts +103 -5
  152. package/src/tests/api_tests/openloop_webhooks.test.ts +138 -0
  153. package/src/tests/api_tests/phone_calls_cold_transfer.test.ts +54 -0
  154. package/src/tests/api_tests/phone_calls_conference_hold.test.ts +62 -0
  155. package/src/tests/api_tests/scoped_api_keys.test.ts +5 -0
  156. package/src/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.ts +215 -0
  157. package/src/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.ts +38 -4
  158. package/src/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.ts +721 -0
  159. package/src/tests/api_tests/security/enduser-ai-summary-trust-gate.test.ts +125 -0
  160. package/src/tests/api_tests/security/formsort_webhook_auth_field_lock.test.ts +753 -0
  161. package/src/tests/api_tests/security/public_endpoint_auth_field_lock.test.ts +1707 -0
  162. package/src/tests/api_tests/tickets_bulk_assign_care_team.test.ts +132 -0
  163. package/src/tests/tests.ts +232 -2
  164. package/test_generated.pdf +0 -0
  165. 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
  )
@@ -408,8 +408,100 @@ export const custom_dashboards_tests = async ({ sdk, sdkNonAdmin }: { sdk: Sessi
408
408
  { shouldError: true, onError: () => true }
409
409
  )
410
410
 
411
+ // Test 10: Dashboard mutations stream through /v1/data-sync
412
+ const syncFrom = new Date()
413
+ const dashboard_sync_records = (results: { modelName: string, recordId: string, data: string }[], recordId: string) => (
414
+ results.filter(r => r.modelName === 'custom_dashboards' && r.recordId === recordId)
415
+ )
416
+
417
+ const syncedDashboard = await sdk.api.custom_dashboards.createOne({
418
+ title: "Sync Create Dashboard",
419
+ blocks: [{ type: "Inbox", colSpan: 12 }],
420
+ })
421
+ await wait(undefined, 100) // sync_record is not awaited by the mutation handler
422
+
423
+ await async_test(
424
+ "Dashboard create streams to sync, admin",
425
+ () => sdk.sync({ from: syncFrom }),
426
+ {
427
+ onResult: ({ results }) => {
428
+ const records = dashboard_sync_records(results, syncedDashboard.id)
429
+ return (
430
+ records.length === 1
431
+ && records[0].data !== 'deleted'
432
+ && JSON.parse(records[0].data).title === "Sync Create Dashboard"
433
+ )
434
+ }
435
+ }
436
+ )
437
+
438
+ await sdk.api.custom_dashboards.updateOne(syncedDashboard.id, { title: "Sync Update Dashboard" })
439
+ await wait(undefined, 100)
440
+
441
+ await async_test(
442
+ "Dashboard update streams to sync, admin",
443
+ () => sdk.sync({ from: syncFrom }),
444
+ {
445
+ onResult: ({ results }) => {
446
+ const records = dashboard_sync_records(results, syncedDashboard.id)
447
+ return (
448
+ records.length === 1
449
+ && records[0].data !== 'deleted'
450
+ && JSON.parse(records[0].data).title === "Sync Update Dashboard"
451
+ )
452
+ }
453
+ }
454
+ )
455
+
456
+ await sdk.api.custom_dashboards.deleteOne(syncedDashboard.id)
457
+ await wait(undefined, 100)
458
+
459
+ await async_test(
460
+ "Dashboard delete streams to sync, admin",
461
+ () => sdk.sync({ from: syncFrom }),
462
+ {
463
+ onResult: ({ results }) => {
464
+ const records = dashboard_sync_records(results, syncedDashboard.id)
465
+ return records.length === 1 && records[0].data === 'deleted'
466
+ }
467
+ }
468
+ )
469
+
470
+ // Restricted user (read: Default) only receives org-wide and assigned dashboards
471
+ const restrictedSyncFrom = new Date()
472
+
473
+ // re-save each fixture so it lands in the restricted user's sync window
474
+ await Promise.all([
475
+ sdk.api.custom_dashboards.updateOne(orgWideDashboard.id, { description: "org-wide sync" }),
476
+ sdk.api.custom_dashboards.updateOne(assignedDashboard.id, { description: "assigned sync" }),
477
+ sdk.api.custom_dashboards.updateOne(hiddenDashboard.id, { description: "hidden sync" }),
478
+ sdk.api.custom_dashboards.updateOne(explicitFalseDashboard.id, { description: "explicit false sync" }),
479
+ ])
480
+ await wait(undefined, 100)
481
+
482
+ await async_test(
483
+ "Restricted user syncs org-wide and assigned dashboards only",
484
+ () => sdkRestricted.sync({ from: restrictedSyncFrom }),
485
+ {
486
+ onResult: ({ results }) => (
487
+ dashboard_sync_records(results, orgWideDashboard.id).length === 1
488
+ && dashboard_sync_records(results, assignedDashboard.id).length === 1
489
+ && dashboard_sync_records(results, hiddenDashboard.id).length === 0
490
+ && dashboard_sync_records(results, explicitFalseDashboard.id).length === 0
491
+ )
492
+ }
493
+ )
494
+
411
495
  // Toggling the flag on takes effect for the restricted user
496
+ const toggleSyncFrom = new Date()
412
497
  await sdk.api.custom_dashboards.updateOne(hiddenDashboard.id, { visibleToAllUsers: true })
498
+ await wait(undefined, 100)
499
+
500
+ await async_test(
501
+ "Restricted user syncs dashboard after visibleToAllUsers enabled",
502
+ () => sdkRestricted.sync({ from: toggleSyncFrom }),
503
+ { onResult: ({ results }) => dashboard_sync_records(results, hiddenDashboard.id).length === 1 }
504
+ )
413
505
 
414
506
  await async_test(
415
507
  "Restricted user can read dashboard after visibleToAllUsers enabled",
@@ -0,0 +1,223 @@
1
+ require('source-map-support').install();
2
+
3
+ import axios from "axios"
4
+ import * as buffer from 'buffer'
5
+ import { Session } from "../../sdk"
6
+ import {
7
+ assert,
8
+ log_header,
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
+ /**
15
+ * A presigned download URL signs response-content-disposition, which S3 validates: it rejects any
16
+ * value containing a character above U+00FF with 400 InvalidArgument "Header value cannot be
17
+ * represented using ISO-8859-1", and one with unbalanced quoting with "open quoted span". Because
18
+ * the parameter is part of the signature it cannot be repaired after the fact, so before the fix a
19
+ * file named with an em dash or a decomposed accent got a 200 from our API and a permanent 400 from
20
+ * S3 on every fetch.
21
+ *
22
+ * This test is the end-to-end check: it uploads files with those names and actually fetches the
23
+ * returned URL, which is the only thing that exercises S3's validation.
24
+ */
25
+
26
+ // Built from code points rather than written out, so that this file stays pure ASCII and so that a
27
+ // combining accent (which renders as a mark on the preceding character) is unambiguous in source.
28
+ const c = (n: number) => String.fromCharCode(n)
29
+ const EM_DASH = c(0x2014)
30
+ const EN_DASH = c(0x2013)
31
+ const RIGHT_SINGLE_Q = c(0x2019)
32
+ const COMBINING_ACUTE = c(0x0301)
33
+ const CJK_BAO_GAO = c(0x62a5) + c(0x544a)
34
+
35
+ const IS_ASCII_ONLY = /^[\x20-\x7e]*$/
36
+
37
+ // The first three are the real filenames from the bug report.
38
+ const TEST_NAMES: { name: string, label: string, expectExtValue: boolean }[] = [
39
+ {
40
+ name: `First Aid Classes Information${EM_DASH}NWLI.pdf`,
41
+ label: 'em dash (U+2014)',
42
+ expectExtValue: true,
43
+ },
44
+ {
45
+ name: `CHA${RIGHT_SINGLE_Q}s Waitlist Portal ${EN_DASH} Apply.pdf`,
46
+ label: 'right single quote (U+2019) and en dash (U+2013)',
47
+ expectExtValue: true,
48
+ },
49
+ {
50
+ name: `Ren${'e' + COMBINING_ACUTE} Test .pdf`,
51
+ label: 'decomposed accent (e + U+0301)',
52
+ expectExtValue: true,
53
+ },
54
+ {
55
+ name: `He said "hi".pdf`,
56
+ label: 'a double quote, which S3 rejects as an open quoted span',
57
+ expectExtValue: true,
58
+ },
59
+ {
60
+ name: `${CJK_BAO_GAO}.pdf`,
61
+ label: 'an entirely non-ASCII name, so the fallback cannot come from the name',
62
+ expectExtValue: true,
63
+ },
64
+ {
65
+ name: `plain ascii control.pdf`,
66
+ label: 'control: a plain ASCII name that always worked',
67
+ expectExtValue: true, // the space means filename* is emitted, carrying the real spaces
68
+ },
69
+ ]
70
+
71
+ export const file_download_unicode_names_tests = async ({ sdk }: { sdk: Session, sdkNonAdmin: Session }) => {
72
+ log_header("File Download URL with Unicode Filenames")
73
+
74
+ const fileIds: string[] = []
75
+
76
+ const uploadFile = async (name: string) => {
77
+ const buff = buffer.Buffer.from(`test file content for ${JSON.stringify(name)}`)
78
+ const { presignedUpload, file } = await sdk.api.files.prepare_file_upload({
79
+ name,
80
+ type: 'application/pdf',
81
+ size: buff.byteLength,
82
+ })
83
+ await sdk.UPLOAD(presignedUpload as any, buff)
84
+ await sdk.api.files.confirm_file_upload({ id: file.id })
85
+ fileIds.push(file.id)
86
+ return { file, contents: buff }
87
+ }
88
+
89
+ // validateStatus so that a failure reports S3's actual status and XML <Message>, rather than an
90
+ // opaque axios throw. That is what makes this test diagnostic instead of merely red.
91
+ const fetchURL = (url: string) => axios.get(url, {
92
+ responseType: 'arraybuffer',
93
+ validateStatus: () => true,
94
+ })
95
+
96
+ try {
97
+ for (const { name, label, expectExtValue } of TEST_NAMES) {
98
+ const { file, contents } = await uploadFile(name)
99
+
100
+ const { downloadURL, name: returnedName } = await sdk.api.files.file_download_URL({
101
+ secureName: file.secureName,
102
+ })
103
+
104
+ assert(
105
+ returnedName === name,
106
+ `endpoint returned "${returnedName}" instead of the stored name for ${label}`,
107
+ `stored name round-trips for ${label}`,
108
+ )
109
+
110
+ const response = await fetchURL(downloadURL)
111
+
112
+ // the bug: our API returned 200, S3 returned 400 on every fetch of the URL it gave us
113
+ assert(
114
+ response.status === 200,
115
+ `S3 returned ${response.status} for ${label}: ${buffer.Buffer.from(response.data ?? '').toString().slice(0, 400)}`,
116
+ `S3 returns 200 for ${label}`,
117
+ )
118
+
119
+ assert(
120
+ buffer.Buffer.from(response.data).toString() === contents.toString(),
121
+ `downloaded bytes did not match for ${label}`,
122
+ `downloaded bytes match for ${label}`,
123
+ )
124
+
125
+ const disposition = (response.headers['content-disposition'] ?? '') as string
126
+
127
+ assert(
128
+ IS_ASCII_ONLY.test(disposition),
129
+ `Content-Disposition was not ASCII-only for ${label}: ${disposition}`,
130
+ `Content-Disposition is ASCII-only for ${label}`,
131
+ )
132
+ assert(
133
+ disposition.startsWith('attachment;'),
134
+ `expected an attachment disposition for ${label}, got: ${disposition}`,
135
+ `disposition is attachment for ${label}`,
136
+ )
137
+ assert(
138
+ expectExtValue === disposition.includes(`filename*=UTF-8''`),
139
+ `filename* presence was wrong for ${label}: ${disposition}`,
140
+ `filename* ${expectExtValue ? 'present' : 'absent'} for ${label}`,
141
+ )
142
+ }
143
+
144
+ /* ---------------------------------------------------------------- *
145
+ * The download URL is cached server-side. preferInBrowser changes
146
+ * whether a Content-Disposition is signed into the URL at all, so it
147
+ * has to be part of the cache key - otherwise whichever variant is
148
+ * requested first is served to everyone for ~99 minutes.
149
+ * ---------------------------------------------------------------- */
150
+ const { file: cacheFile, contents: cacheContents } = await uploadFile(
151
+ `Cache Test${EM_DASH}Both Variants.pdf`
152
+ )
153
+
154
+ const asAttachment = await sdk.api.files.file_download_URL({ secureName: cacheFile.secureName })
155
+ const inBrowser = await sdk.api.files.file_download_URL({
156
+ secureName: cacheFile.secureName,
157
+ preferInBrowser: true,
158
+ })
159
+
160
+ assert(
161
+ asAttachment.downloadURL !== inBrowser.downloadURL,
162
+ 'preferInBrowser returned the cached attachment URL, so the cache key ignores it',
163
+ 'preferInBrowser is cached separately from the attachment URL',
164
+ )
165
+ assert(
166
+ asAttachment.downloadURL.includes('response-content-disposition'),
167
+ 'the default URL was signed without a content disposition',
168
+ 'the default URL signs a content disposition',
169
+ )
170
+ assert(
171
+ !inBrowser.downloadURL.includes('response-content-disposition'),
172
+ 'the preferInBrowser URL was signed with a content disposition',
173
+ 'the preferInBrowser URL omits the content disposition',
174
+ )
175
+
176
+ for (const [variant, { downloadURL }] of [['attachment', asAttachment], ['inBrowser', inBrowser]] as const) {
177
+ const response = await fetchURL(downloadURL)
178
+ assert(
179
+ response.status === 200,
180
+ `S3 returned ${response.status} for the ${variant} URL: ${buffer.Buffer.from(response.data ?? '').toString().slice(0, 400)}`,
181
+ `S3 returns 200 for the ${variant} URL`,
182
+ )
183
+ assert(
184
+ buffer.Buffer.from(response.data).toString() === cacheContents.toString(),
185
+ `downloaded bytes did not match for the ${variant} URL`,
186
+ `downloaded bytes match for the ${variant} URL`,
187
+ )
188
+ }
189
+
190
+ // a repeat request with identical arguments must still hit the cache
191
+ const repeated = await sdk.api.files.file_download_URL({ secureName: cacheFile.secureName })
192
+ assert(
193
+ repeated.downloadURL === asAttachment.downloadURL,
194
+ 'an identical repeat request did not hit the cache',
195
+ 'an identical repeat request hits the cache',
196
+ )
197
+ } finally {
198
+ for (const id of fileIds) {
199
+ await sdk.api.files.deleteOne(id).catch(() => {})
200
+ }
201
+ }
202
+ }
203
+
204
+ if (require.main === module) {
205
+ console.log(`Using API URL: ${host}`)
206
+ const sdk = new Session({ host })
207
+ const sdkNonAdmin = new Session({ host })
208
+
209
+ const runTests = async () => {
210
+ await setup_tests(sdk, sdkNonAdmin)
211
+ await file_download_unicode_names_tests({ sdk, sdkNonAdmin })
212
+ }
213
+
214
+ runTests()
215
+ .then(() => {
216
+ console.log("File download unicode names test suite completed successfully")
217
+ process.exit(0)
218
+ })
219
+ .catch((error) => {
220
+ console.error("File download unicode names test suite failed:", error)
221
+ process.exit(1)
222
+ })
223
+ }
@@ -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