@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.
- package/lib/cjs/sdk.d.ts +7 -2
- package/lib/cjs/sdk.d.ts.map +1 -1
- package/lib/cjs/sdk.js +1 -0
- package/lib/cjs/sdk.js.map +1 -1
- package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.js +18 -8
- package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/custom_dashboards.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/custom_dashboards.test.js +99 -8
- package/lib/cjs/tests/api_tests/custom_dashboards.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/default_summary_journey.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/default_summary_journey.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/file_download_unicode_names.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/file_download_unicode_names.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/file_download_unicode_names.test.js +278 -0
- package/lib/cjs/tests/api_tests/file_download_unicode_names.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/healthie_multi_integration.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js +203 -72
- package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/openloop_webhooks.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/openloop_webhooks.test.js +212 -33
- package/lib/cjs/tests/api_tests/openloop_webhooks.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.js +85 -9
- package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.js +102 -0
- package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/scoped_api_keys.test.js +84 -81
- package/lib/cjs/tests/api_tests/scoped_api_keys.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts +2 -0
- package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js +242 -0
- package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts +29 -0
- package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js +443 -0
- package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts +35 -0
- package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js +231 -0
- package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +121 -78
- package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +1003 -0
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +17 -0
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +215 -0
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js +1200 -0
- package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +2330 -0
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.js +204 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.js.map +1 -0
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +489 -255
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/esm/enduser.d.ts +0 -1
- package/lib/esm/enduser.d.ts.map +1 -1
- package/lib/esm/sdk.d.ts +9 -5
- package/lib/esm/sdk.d.ts.map +1 -1
- package/lib/esm/sdk.js +1 -0
- package/lib/esm/sdk.js.map +1 -1
- package/lib/esm/session.d.ts +0 -1
- package/lib/esm/session.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/auto_merge_form_submission.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/auto_merge_form_submission.test.js +18 -8
- package/lib/esm/tests/api_tests/auto_merge_form_submission.test.js.map +1 -1
- package/lib/esm/tests/api_tests/custom_dashboards.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/custom_dashboards.test.js +99 -8
- package/lib/esm/tests/api_tests/custom_dashboards.test.js.map +1 -1
- package/lib/esm/tests/api_tests/default_summary_journey.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/default_summary_journey.test.js.map +1 -0
- package/lib/esm/tests/api_tests/file_download_unicode_names.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/file_download_unicode_names.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/file_download_unicode_names.test.js +248 -0
- package/lib/esm/tests/api_tests/file_download_unicode_names.test.js.map +1 -0
- package/lib/esm/tests/api_tests/healthie_multi_integration.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/healthie_multi_integration.test.js +205 -74
- package/lib/esm/tests/api_tests/healthie_multi_integration.test.js.map +1 -1
- package/lib/esm/tests/api_tests/openloop_webhooks.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/openloop_webhooks.test.js +213 -34
- package/lib/esm/tests/api_tests/openloop_webhooks.test.js.map +1 -1
- package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.js +85 -9
- package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.js.map +1 -1
- package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.js +98 -0
- package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.js.map +1 -0
- package/lib/esm/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/scoped_api_keys.test.js +84 -81
- package/lib/esm/tests/api_tests/scoped_api_keys.test.js.map +1 -1
- package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts +2 -0
- package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js +238 -0
- package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts +29 -0
- package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js +436 -0
- package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts +35 -0
- package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js +224 -0
- package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +121 -78
- package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -1
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +996 -0
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +17 -0
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +211 -0
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js +1193 -0
- package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +2323 -0
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.js +200 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.js.map +1 -0
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +489 -255
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/sdk.ts +4 -0
- package/src/tests/api_tests/auto_merge_form_submission.test.ts +13 -4
- package/src/tests/api_tests/custom_dashboards.test.ts +92 -0
- package/src/tests/api_tests/file_download_unicode_names.test.ts +223 -0
- package/src/tests/api_tests/healthie_multi_integration.test.ts +103 -5
- package/src/tests/api_tests/openloop_webhooks.test.ts +138 -0
- package/src/tests/api_tests/phone_calls_cold_transfer.test.ts +54 -0
- package/src/tests/api_tests/phone_calls_conference_hold.test.ts +62 -0
- package/src/tests/api_tests/scoped_api_keys.test.ts +5 -0
- package/src/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.ts +215 -0
- package/src/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.ts +38 -4
- package/src/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.ts +721 -0
- package/src/tests/api_tests/security/enduser-ai-summary-trust-gate.test.ts +125 -0
- package/src/tests/api_tests/security/formsort_webhook_auth_field_lock.test.ts +753 -0
- package/src/tests/api_tests/security/public_endpoint_auth_field_lock.test.ts +1707 -0
- package/src/tests/api_tests/tickets_bulk_assign_care_team.test.ts +132 -0
- package/src/tests/tests.ts +232 -2
- package/test_generated.pdf +0 -0
- package/.env +0 -13
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
assert,
|
|
6
6
|
async_test,
|
|
7
7
|
log_header,
|
|
8
|
+
wait,
|
|
8
9
|
} from "@tellescope/testing"
|
|
9
10
|
import { setup_tests } from "../setup"
|
|
10
11
|
|
|
@@ -80,9 +81,12 @@ export const openloop_webhooks_tests = async ({ sdk, sdkNonAdmin }: { sdk: Sessi
|
|
|
80
81
|
|
|
81
82
|
const healthieId1 = 'test-healthie-ol-1'
|
|
82
83
|
const healthieId2 = 'test-healthie-ol-2'
|
|
84
|
+
const healthieId3 = 'test-healthie-ol-3'
|
|
83
85
|
|
|
84
86
|
const enduser1 = await sdk.api.endusers.createOne({ source: 'Healthie', externalId: healthieId1 })
|
|
85
87
|
const enduser2 = await sdk.api.endusers.createOne({ source: 'Healthie', externalId: healthieId2 })
|
|
88
|
+
// dedicated to the frequency-trigger section, so tag assertions stay isolated
|
|
89
|
+
const enduser3 = await sdk.api.endusers.createOne({ source: 'Healthie', externalId: healthieId3 })
|
|
86
90
|
|
|
87
91
|
try {
|
|
88
92
|
// ===== SECTION A: V1 Validation =====
|
|
@@ -272,6 +276,84 @@ export const openloop_webhooks_tests = async ({ sdk, sdkNonAdmin }: { sdk: Sessi
|
|
|
272
276
|
{ onResult: (r: boolean) => r === true }
|
|
273
277
|
)
|
|
274
278
|
|
|
279
|
+
await async_test(
|
|
280
|
+
'V1: order_shipped updates frequency when weeksOrdered changes',
|
|
281
|
+
async () => {
|
|
282
|
+
const orderNum = `ol-ship-weeks-${uid()}`
|
|
283
|
+
// OLH confirms with weeksOrdered=w4
|
|
284
|
+
await postV1(makeV1Confirmation({
|
|
285
|
+
patientID: healthieId1,
|
|
286
|
+
orderNumber: orderNum,
|
|
287
|
+
weeksOrdered: 'w4',
|
|
288
|
+
}))
|
|
289
|
+
|
|
290
|
+
// ...then ships the same order with a different weeksOrdered, which is the value to trigger on
|
|
291
|
+
const res = await postV1(makeV1Shipped({
|
|
292
|
+
patientID: healthieId1,
|
|
293
|
+
orderNumber: orderNum,
|
|
294
|
+
weeksOrdered: 'w8',
|
|
295
|
+
}))
|
|
296
|
+
assert(res.status === 200, `Expected 200, got ${res.status}`)
|
|
297
|
+
|
|
298
|
+
const orders = await sdk.api.enduser_orders.getSome({
|
|
299
|
+
filter: { source: 'OpenLoop', externalId: orderNum }
|
|
300
|
+
})
|
|
301
|
+
assert(orders.length === 1, `Expected 1 order, got ${orders.length}`)
|
|
302
|
+
assert(orders[0].frequency === 'w8', `frequency should reflect the shipped event: ${orders[0].frequency}`)
|
|
303
|
+
return true
|
|
304
|
+
},
|
|
305
|
+
{ onResult: (r: boolean) => r === true }
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
await async_test(
|
|
309
|
+
'V1: order_shipped without weeksOrdered preserves confirmed frequency',
|
|
310
|
+
async () => {
|
|
311
|
+
const orderNum = `ol-ship-noweeks-${uid()}`
|
|
312
|
+
await postV1(makeV1Confirmation({
|
|
313
|
+
patientID: healthieId1,
|
|
314
|
+
orderNumber: orderNum,
|
|
315
|
+
weeksOrdered: 'w4',
|
|
316
|
+
}))
|
|
317
|
+
|
|
318
|
+
// makeV1Shipped has no weeksOrdered default, so this ships without restating it
|
|
319
|
+
const res = await postV1(makeV1Shipped({
|
|
320
|
+
patientID: healthieId1,
|
|
321
|
+
orderNumber: orderNum,
|
|
322
|
+
}))
|
|
323
|
+
assert(res.status === 200, `Expected 200, got ${res.status}`)
|
|
324
|
+
|
|
325
|
+
const orders = await sdk.api.enduser_orders.getSome({
|
|
326
|
+
filter: { source: 'OpenLoop', externalId: orderNum }
|
|
327
|
+
})
|
|
328
|
+
assert(orders.length === 1, `Expected 1 order, got ${orders.length}`)
|
|
329
|
+
assert(orders[0].frequency === 'w4', `frequency should not be cleared: ${orders[0].frequency}`)
|
|
330
|
+
return true
|
|
331
|
+
},
|
|
332
|
+
{ onResult: (r: boolean) => r === true }
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
await async_test(
|
|
336
|
+
'V1: order_shipped creates new order with frequency',
|
|
337
|
+
async () => {
|
|
338
|
+
const orderNum = `ol-ship-new-weeks-${uid()}`
|
|
339
|
+
// never confirmed, first seen as shipped
|
|
340
|
+
const res = await postV1(makeV1Shipped({
|
|
341
|
+
patientID: healthieId1,
|
|
342
|
+
orderNumber: orderNum,
|
|
343
|
+
weeksOrdered: 'w12',
|
|
344
|
+
}))
|
|
345
|
+
assert(res.status === 200, `Expected 200, got ${res.status}`)
|
|
346
|
+
|
|
347
|
+
const orders = await sdk.api.enduser_orders.getSome({
|
|
348
|
+
filter: { source: 'OpenLoop', externalId: orderNum }
|
|
349
|
+
})
|
|
350
|
+
assert(orders.length === 1, `Expected 1 order, got ${orders.length}`)
|
|
351
|
+
assert(orders[0].frequency === 'w12', `frequency mismatch: ${orders[0].frequency}`)
|
|
352
|
+
return true
|
|
353
|
+
},
|
|
354
|
+
{ onResult: (r: boolean) => r === true }
|
|
355
|
+
)
|
|
356
|
+
|
|
275
357
|
await async_test(
|
|
276
358
|
'V1: order_shipped creates new order if none exists',
|
|
277
359
|
async () => {
|
|
@@ -695,11 +777,67 @@ export const openloop_webhooks_tests = async ({ sdk, sdkNonAdmin }: { sdk: Sessi
|
|
|
695
777
|
{ onResult: (r: boolean) => r === true }
|
|
696
778
|
)
|
|
697
779
|
|
|
780
|
+
// ===== SECTION J: V1 weeksOrdered drives Order Status Equals triggers =====
|
|
781
|
+
// The reason the shipped event's weeksOrdered matters: it is what the frequencies filter matches on.
|
|
782
|
+
log_header("V1 weeksOrdered trigger integration")
|
|
783
|
+
|
|
784
|
+
// Runs last so these OpenLoop/shipped triggers can't tag endusers used by earlier sections.
|
|
785
|
+
const shippedTrigger = await sdk.api.automation_triggers.createOne({
|
|
786
|
+
event: { type: 'Order Status Equals', info: { source: 'OpenLoop', status: 'shipped', frequencies: ['w8'] } },
|
|
787
|
+
action: { type: 'Add Tags', info: { tags: ['OLH Shipped w8'] } },
|
|
788
|
+
status: 'Active',
|
|
789
|
+
title: "OpenLoop shipped with weeksOrdered w8",
|
|
790
|
+
})
|
|
791
|
+
const staleTrigger = await sdk.api.automation_triggers.createOne({
|
|
792
|
+
event: { type: 'Order Status Equals', info: { source: 'OpenLoop', status: 'shipped', frequencies: ['w4'] } },
|
|
793
|
+
action: { type: 'Add Tags', info: { tags: ['OLH Shipped w4'] } },
|
|
794
|
+
status: 'Active',
|
|
795
|
+
title: "OpenLoop shipped with weeksOrdered w4",
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
try {
|
|
799
|
+
await async_test(
|
|
800
|
+
'V1: shipped weeksOrdered is what Order Status Equals matches on',
|
|
801
|
+
async () => {
|
|
802
|
+
const orderNum = `ol-ship-trigger-${uid()}`
|
|
803
|
+
await postV1(makeV1Confirmation({
|
|
804
|
+
patientID: healthieId3,
|
|
805
|
+
orderNumber: orderNum,
|
|
806
|
+
weeksOrdered: 'w4',
|
|
807
|
+
}))
|
|
808
|
+
const res = await postV1(makeV1Shipped({
|
|
809
|
+
patientID: healthieId3,
|
|
810
|
+
orderNumber: orderNum,
|
|
811
|
+
weeksOrdered: 'w8',
|
|
812
|
+
}))
|
|
813
|
+
assert(res.status === 200, `Expected 200, got ${res.status}`)
|
|
814
|
+
|
|
815
|
+
await wait(undefined, 2000) // allow triggers to happen
|
|
816
|
+
|
|
817
|
+
const updated = await sdk.api.endusers.getOne(enduser3.id)
|
|
818
|
+
assert(
|
|
819
|
+
!!updated.tags?.includes('OLH Shipped w8'),
|
|
820
|
+
`expected the shipped weeksOrdered to fire its trigger, tags: ${JSON.stringify(updated.tags)}`
|
|
821
|
+
)
|
|
822
|
+
assert(
|
|
823
|
+
!updated.tags?.includes('OLH Shipped w4'),
|
|
824
|
+
`the stale confirmed weeksOrdered should not fire, tags: ${JSON.stringify(updated.tags)}`
|
|
825
|
+
)
|
|
826
|
+
return true
|
|
827
|
+
},
|
|
828
|
+
{ onResult: (r: boolean) => r === true }
|
|
829
|
+
)
|
|
830
|
+
} finally {
|
|
831
|
+
await sdk.api.automation_triggers.deleteOne(shippedTrigger.id).catch(console.error)
|
|
832
|
+
await sdk.api.automation_triggers.deleteOne(staleTrigger.id).catch(console.error)
|
|
833
|
+
}
|
|
834
|
+
|
|
698
835
|
console.log("All OpenLoop webhook tests passed!")
|
|
699
836
|
|
|
700
837
|
} finally {
|
|
701
838
|
await sdk.api.endusers.deleteOne(enduser1.id).catch(console.error)
|
|
702
839
|
await sdk.api.endusers.deleteOne(enduser2.id).catch(console.error)
|
|
840
|
+
await sdk.api.endusers.deleteOne(enduser3.id).catch(console.error)
|
|
703
841
|
}
|
|
704
842
|
}
|
|
705
843
|
|
|
@@ -6,11 +6,13 @@ import {
|
|
|
6
6
|
handleAnyError,
|
|
7
7
|
log_header,
|
|
8
8
|
} from "@tellescope/testing"
|
|
9
|
+
import { FULL_ACCESS, PROVIDER_PERMISSIONS } from "@tellescope/constants"
|
|
9
10
|
import { setup_tests } from "../setup"
|
|
10
11
|
|
|
11
12
|
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
12
13
|
|
|
13
14
|
const NONEXISTENT_MONGO_ID = '60398b1131a295e64f084fff'
|
|
15
|
+
const RAND = () => Math.random().toString(36).slice(2, 10)
|
|
14
16
|
|
|
15
17
|
// Validation-path tests for the cold transfer endpoint. All assertions below hit
|
|
16
18
|
// validations that run before any Twilio access, so no Twilio config is needed.
|
|
@@ -19,6 +21,7 @@ export const phone_calls_cold_transfer_tests = async ({ sdk, sdkNonAdmin } : { s
|
|
|
19
21
|
|
|
20
22
|
const enduser = await sdk.api.endusers.createOne({ fname: 'Cold', lname: 'Transfer' })
|
|
21
23
|
const targetUsers: { id: string }[] = []
|
|
24
|
+
const createdRoleIds: string[] = []
|
|
22
25
|
|
|
23
26
|
try {
|
|
24
27
|
const inboundCall = await sdk.api.phone_calls.createOne({
|
|
@@ -81,11 +84,62 @@ export const phone_calls_cold_transfer_tests = async ({ sdk, sdkNonAdmin } : { s
|
|
|
81
84
|
() => sdkNonAdmin.api.phone_calls.cold_transfer({ callSid: inboundCall.externalId, targetUserId: disabledTargetUser.id }),
|
|
82
85
|
handleAnyError
|
|
83
86
|
)
|
|
87
|
+
|
|
88
|
+
// ── target-user lookup must be gated by users READ access, not the endpoint's update access ──
|
|
89
|
+
// PROVIDER_PERMISSIONS already models the reported bug: users read='All', update='Default'.
|
|
90
|
+
// phone_calls gets FULL_ACCESS so the call itself is visible under update-level filtering.
|
|
91
|
+
const mkRoleUser = async (permissions: any) => {
|
|
92
|
+
const role = `cold-transfer-${RAND()}`
|
|
93
|
+
const rbap = await sdk.api.role_based_access_permissions.createOne({ role, permissions })
|
|
94
|
+
createdRoleIds.push(rbap.id)
|
|
95
|
+
|
|
96
|
+
const user = await sdk.api.users.createOne({
|
|
97
|
+
email: `cold.transfer.${RAND()}@tellescope.example`,
|
|
98
|
+
fname: 'Cold', lname: 'TransferRole',
|
|
99
|
+
notificationEmailsDisabled: true, verifiedEmail: true,
|
|
100
|
+
roles: [role],
|
|
101
|
+
} as any)
|
|
102
|
+
targetUsers.push(user)
|
|
103
|
+
|
|
104
|
+
const { authToken, user: sessionUser } = await sdk.api.users.generate_auth_token({ id: user.id })
|
|
105
|
+
const session = new Session({ host })
|
|
106
|
+
session.setAuthToken(authToken)
|
|
107
|
+
session.setUserInfo(sessionUser as any)
|
|
108
|
+
return session
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const sdkUpdateDefault = await mkRoleUser({ ...PROVIDER_PERMISSIONS, phone_calls: FULL_ACCESS })
|
|
112
|
+
const sdkReadDefault = await mkRoleUser({
|
|
113
|
+
...PROVIDER_PERMISSIONS,
|
|
114
|
+
phone_calls: FULL_ACCESS,
|
|
115
|
+
users: { create: null, read: 'Default', update: 'Default', delete: null },
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// regression: previously failed with "Target user not found" because the lookup was
|
|
119
|
+
// filtered by users update='Default' (self-only); getting past it to the
|
|
120
|
+
// incoming-calls-disabled validation proves the lookup now uses read access
|
|
121
|
+
await async_test(
|
|
122
|
+
"cold_transfer: users update=Default no longer blocks the target lookup",
|
|
123
|
+
() => sdkUpdateDefault.api.phone_calls.cold_transfer({ callSid: inboundCall.externalId, targetUserId: disabledTargetUser.id }),
|
|
124
|
+
{ shouldError: true, onError: (e: any) => e.message === "Target user has incoming calls disabled" }
|
|
125
|
+
)
|
|
126
|
+
// guard: the lookup is read-filtered (not org-wide) — users read='Default' still can't see the target
|
|
127
|
+
await async_test(
|
|
128
|
+
"cold_transfer: users read=Default still hides the target user",
|
|
129
|
+
() => sdkReadDefault.api.phone_calls.cold_transfer({ callSid: inboundCall.externalId, targetUserId: disabledTargetUser.id }),
|
|
130
|
+
{ shouldError: true, onError: (e: any) => e.message === "Target user not found" }
|
|
131
|
+
)
|
|
132
|
+
await async_test(
|
|
133
|
+
"cold_transfer: nonexistent target still rejected for restricted role",
|
|
134
|
+
() => sdkUpdateDefault.api.phone_calls.cold_transfer({ callSid: inboundCall.externalId, targetUserId: NONEXISTENT_MONGO_ID }),
|
|
135
|
+
{ shouldError: true, onError: (e: any) => e.message === "Target user not found" }
|
|
136
|
+
)
|
|
84
137
|
} finally {
|
|
85
138
|
// deleting the enduser cascades deletion of its phone_calls records
|
|
86
139
|
await Promise.all([
|
|
87
140
|
sdk.api.endusers.deleteOne(enduser.id),
|
|
88
141
|
...targetUsers.map(u => sdk.api.users.deleteOne(u.id)),
|
|
142
|
+
...createdRoleIds.map(id => sdk.api.role_based_access_permissions.deleteOne(id)),
|
|
89
143
|
]).catch(console.error)
|
|
90
144
|
}
|
|
91
145
|
}
|
|
@@ -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
|
+
}
|
|
@@ -78,6 +78,11 @@ export const scoped_api_keys_tests = async ({ sdk, sdkNonAdmin } : { sdk: Sessio
|
|
|
78
78
|
}),
|
|
79
79
|
{ shouldError: true, onError: (e: any) => e.response?.status === 401 },
|
|
80
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
|
+
)
|
|
81
86
|
|
|
82
87
|
// =============================================
|
|
83
88
|
// (c) Scoped key IS accepted by the form-ingestion webhook
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { DEFAULT_IFRAME_SANDBOX, replace_form_field_template_values, sanitize_user_html, sanitize_user_html_with_iframes } from "@tellescope/utilities"
|
|
2
|
+
|
|
3
|
+
// Companion to F-0013-sanitize-user-html.test.ts (pattern 06 — XSS via dangerouslySetInnerHTML).
|
|
4
|
+
//
|
|
5
|
+
// sanitize_user_html_with_iframes is the variant used by the live form's field description, so
|
|
6
|
+
// admins can embed video / scheduling widgets. It permits <iframe> and NOTHING else beyond what
|
|
7
|
+
// sanitize_user_html allows. This asserts:
|
|
8
|
+
// 1. permitting iframe did not reopen any of the F-0013 vectors (the full payload list is re-run)
|
|
9
|
+
// 2. only absolute https iframes survive — relative, protocol-relative, http, javascript:, data:
|
|
10
|
+
// and srcdoc frames are dropped, along with their fallback content
|
|
11
|
+
// 3. the restrictive sandbox is forced, but an author-supplied sandbox is honored
|
|
12
|
+
// 4. sanitize_user_html itself still strips iframes (the boundary between the two functions)
|
|
13
|
+
//
|
|
14
|
+
// Pure-function test — no Session needed. Runs as part of the main suite and standalone:
|
|
15
|
+
// ./build_cjs.sh && cd packages/public/sdk && node -r dotenv/config lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js
|
|
16
|
+
|
|
17
|
+
const fail = (msg: string) => { throw new Error(msg) }
|
|
18
|
+
|
|
19
|
+
// Same as has_no_executable_vector in the F-0013 test, minus the '<iframe' clause (iframes are
|
|
20
|
+
// expected in this function's output). Everything else must still be neutralized.
|
|
21
|
+
const has_no_executable_vector_allowing_iframe = (out: string) => {
|
|
22
|
+
const o = out.toLowerCase()
|
|
23
|
+
const withoutValues = o.replace(/"[^"]*"/g, '""').replace(/'[^']*'/g, "''")
|
|
24
|
+
return !/\son[a-z]+\s*=/.test(withoutValues) // no live on*= event-handler attribute
|
|
25
|
+
&& !o.includes('javascript:')
|
|
26
|
+
&& !o.includes('vbscript:')
|
|
27
|
+
&& !o.includes('<script')
|
|
28
|
+
&& !o.includes('<svg')
|
|
29
|
+
&& !o.includes('<math')
|
|
30
|
+
&& !o.includes('<object')
|
|
31
|
+
&& !o.includes('<embed')
|
|
32
|
+
&& !o.includes('<form')
|
|
33
|
+
&& !o.includes('<noscript')
|
|
34
|
+
&& !o.includes('<template')
|
|
35
|
+
&& !o.includes('srcdoc') // an inline document is arbitrary markup
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Tokens that must NEVER appear in the forced default. allow-top-navigation is the important one:
|
|
39
|
+
// it is what stops a hostile embed from redirecting the patient's form page to a phishing site.
|
|
40
|
+
const FORBIDDEN_SANDBOX_TOKENS = [
|
|
41
|
+
'allow-top-navigation',
|
|
42
|
+
'allow-downloads',
|
|
43
|
+
'allow-modals',
|
|
44
|
+
'allow-pointer-lock',
|
|
45
|
+
'allow-orientation-lock',
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
export const sanitize_user_html_with_iframes_tests = async () => {
|
|
49
|
+
console.log("Running sanitize_user_html_with_iframes XSS regression tests")
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------------------------
|
|
52
|
+
// 1. Re-run the full F-0013 payload set — permitting iframe must not be a general regression
|
|
53
|
+
// ---------------------------------------------------------------------------------------------
|
|
54
|
+
const xssPayloads: [string, string][] = [
|
|
55
|
+
['img onerror', `<img src=x onerror="alert(document.domain)">`],
|
|
56
|
+
['svg onload', `<svg onload="alert(1)"></svg>`],
|
|
57
|
+
['svg animate onbegin', `<svg><animate onbegin="alert(1)" attributeName="x" dur="1s"></svg>`],
|
|
58
|
+
['details ontoggle', `<details open ontoggle="alert(1)"></details>`],
|
|
59
|
+
['input onfocus autofocus', `<input autofocus onfocus="alert(1)">`],
|
|
60
|
+
['body onpageshow', `<body onpageshow="alert(1)">`],
|
|
61
|
+
['a javascript scheme', `<a href="javascript:alert(1)">x</a>`],
|
|
62
|
+
['a javascript entity-encoded', `<a href="jav	ascript:alert(1)">x</a>`],
|
|
63
|
+
['script tag', `<script>alert(1)</script>`],
|
|
64
|
+
['onerror newline before =', `<img src=x onerror\n="alert(1)">`],
|
|
65
|
+
['onerror mixed case', `<IMG SRC=x OnErRoR="alert(1)">`],
|
|
66
|
+
['marquee onstart', `<marquee onstart="alert(1)">x</marquee>`],
|
|
67
|
+
['mathml mglyph style mxss', `<math><mtext><table><mglyph><style><!--</style><img src=x onerror=alert(1)>`],
|
|
68
|
+
['svg foreignObject', `<svg><foreignObject><img src=x onerror=alert(1)></foreignObject></svg>`],
|
|
69
|
+
['noscript context confusion', `<noscript><p title="</noscript><img src=x onerror=alert(1)>">`],
|
|
70
|
+
['template content', `<template><img src=x onerror=alert(1)></template>`],
|
|
71
|
+
['comment confusion', `<!--><img src=x onerror=alert(1)>-->`],
|
|
72
|
+
['cdata confusion', `<![CDATA[<img src=x onerror=alert(1)>]]>`],
|
|
73
|
+
['markup inside attr value', `<img src="x" alt="<script>alert(1)</script>">`],
|
|
74
|
+
['vbscript scheme', `<a href="vbscript:msgbox(1)">x</a>`],
|
|
75
|
+
['data text/html href', `<a href="data:text/html,<script>alert(1)</script>">x</a>`],
|
|
76
|
+
['javascript decimal entity', `<a href="Javascript:alert(1)">x</a>`],
|
|
77
|
+
['javascript newline entity', `<a href="jav
ascript:alert(1)">x</a>`],
|
|
78
|
+
]
|
|
79
|
+
for (const [name, payload] of xssPayloads) {
|
|
80
|
+
const out = sanitize_user_html_with_iframes(payload)
|
|
81
|
+
if (!has_no_executable_vector_allowing_iframe(out)) fail(`XSS not neutralized [${name}] -> ${out}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------------------------
|
|
85
|
+
// 2. Only absolute https iframes survive
|
|
86
|
+
// ---------------------------------------------------------------------------------------------
|
|
87
|
+
const badSrcs: [string, string][] = [
|
|
88
|
+
['javascript scheme', `javascript:alert(1)`],
|
|
89
|
+
['data text/html', `data:text/html,<script>alert(1)</script>`],
|
|
90
|
+
['http (not https)', `http://evil.example`],
|
|
91
|
+
// sanitize-html does NOT reject protocol-relative iframe srcs on its own when
|
|
92
|
+
// allowProtocolRelative is set — this is the self-origin framing vector and the reason the
|
|
93
|
+
// sanitizer does its own absolute-https check.
|
|
94
|
+
['protocol-relative', `//app.tellescope.com/`],
|
|
95
|
+
['root-relative', `/portal/settings`],
|
|
96
|
+
['bare relative', `settings`],
|
|
97
|
+
['whitespace-obfuscated', ` https:/\\/\\evil`],
|
|
98
|
+
['tab-entity javascript', `jav	ascript:alert(1)`],
|
|
99
|
+
['scheme-only', `https://`],
|
|
100
|
+
['empty', ``],
|
|
101
|
+
]
|
|
102
|
+
for (const [name, src] of badSrcs) {
|
|
103
|
+
const out = sanitize_user_html_with_iframes(`<iframe src="${src}"></iframe>`)
|
|
104
|
+
if (out.toLowerCase().includes('<iframe')) fail(`bad iframe src survived [${name}] -> ${out}`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// A dropped iframe must take its fallback content with it (exclusiveFilter, not a tagName swap)
|
|
108
|
+
const fallback = sanitize_user_html_with_iframes(`<iframe src="/x">FALLBACK-TEXT</iframe>`)
|
|
109
|
+
if (fallback.includes('FALLBACK-TEXT')) fail(`dropped iframe leaked its fallback text: ${fallback}`)
|
|
110
|
+
if (fallback.toLowerCase().includes('<iframe')) fail(`dropped iframe survived: ${fallback}`)
|
|
111
|
+
|
|
112
|
+
// srcdoc is not allowlisted, even alongside a valid src
|
|
113
|
+
const srcdoc = sanitize_user_html_with_iframes(`<iframe src="https://ok.example" srcdoc="<img src=x onerror=alert(1)>"></iframe>`)
|
|
114
|
+
if (srcdoc.toLowerCase().includes('srcdoc')) fail(`srcdoc not stripped: ${srcdoc}`)
|
|
115
|
+
if (!srcdoc.toLowerCase().includes('<iframe')) fail(`valid iframe dropped alongside srcdoc: ${srcdoc}`)
|
|
116
|
+
|
|
117
|
+
// handlers / clobbering attributes stripped, frame itself kept
|
|
118
|
+
const handlers = sanitize_user_html_with_iframes(`<iframe src="https://ok.example" onload="alert(1)" id="x" name="getElementById"></iframe>`)
|
|
119
|
+
if (!handlers.toLowerCase().includes('<iframe')) fail(`valid iframe dropped: ${handlers}`)
|
|
120
|
+
if (/\son[a-z]+\s*=/.test(handlers.toLowerCase())) fail(`iframe event handler survived: ${handlers}`)
|
|
121
|
+
if (/\b(id|name)\s*=/.test(handlers)) fail(`iframe id/name not stripped (DOM clobbering): ${handlers}`)
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------------------------
|
|
124
|
+
// 3. Legitimate embeds survive, hardened
|
|
125
|
+
// ---------------------------------------------------------------------------------------------
|
|
126
|
+
// The exact shape draftjs-to-html emits for the WYSIWYG "embedded" button
|
|
127
|
+
const yt = sanitize_user_html_with_iframes(`<iframe width="560" height="315" src="https://www.youtube.com/embed/abc" frameBorder="0"></iframe>`)
|
|
128
|
+
if (!yt.includes('src="https://www.youtube.com/embed/abc"')) fail(`youtube embed src stripped: ${yt}`)
|
|
129
|
+
if (!(yt.includes('width="560"') && yt.includes('height="315"'))) fail(`iframe dimensions stripped: ${yt}`)
|
|
130
|
+
if (!yt.toLowerCase().includes('frameborder')) fail(`frameborder stripped: ${yt}`)
|
|
131
|
+
|
|
132
|
+
// forced containment
|
|
133
|
+
if (!yt.includes(`sandbox="${DEFAULT_IFRAME_SANDBOX}"`)) fail(`sandbox not forced: ${yt}`)
|
|
134
|
+
if (!yt.toLowerCase().includes('referrerpolicy=')) fail(`referrerpolicy not set: ${yt}`)
|
|
135
|
+
for (const token of FORBIDDEN_SANDBOX_TOKENS) {
|
|
136
|
+
if (DEFAULT_IFRAME_SANDBOX.includes(token)) fail(`forced sandbox must not grant ${token}: ${DEFAULT_IFRAME_SANDBOX}`)
|
|
137
|
+
}
|
|
138
|
+
// allow-same-origin IS granted — required for real embeds (a Loom embed renders blank without it)
|
|
139
|
+
// and harmless because the src is guaranteed cross-origin by the check asserted above.
|
|
140
|
+
if (!DEFAULT_IFRAME_SANDBOX.includes('allow-same-origin')) fail(`forced sandbox must grant allow-same-origin so real embeds render: ${DEFAULT_IFRAME_SANDBOX}`)
|
|
141
|
+
|
|
142
|
+
// author-supplied sandbox honored verbatim, so a surface can tighten (or loosen) per embed.
|
|
143
|
+
// Uses a value distinct from the default, so this can't pass by coincidence.
|
|
144
|
+
const authorSandbox = sanitize_user_html_with_iframes(`<iframe src="https://ok.example" sandbox="allow-scripts"></iframe>`)
|
|
145
|
+
if (!authorSandbox.includes('sandbox="allow-scripts"')) fail(`author sandbox not honored: ${authorSandbox}`)
|
|
146
|
+
|
|
147
|
+
// sandbox="" is the MOST restrictive value and must not be replaced by the default
|
|
148
|
+
const emptySandbox = sanitize_user_html_with_iframes(`<iframe src="https://ok.example" sandbox=""></iframe>`)
|
|
149
|
+
if (emptySandbox.includes('allow-')) fail(`empty sandbox was replaced with the permissive default: ${emptySandbox}`)
|
|
150
|
+
if (!/\ssandbox(\s|>)/.test(emptySandbox)) fail(`empty sandbox attribute lost entirely: ${emptySandbox}`)
|
|
151
|
+
|
|
152
|
+
// author referrerpolicy honored
|
|
153
|
+
const noref = sanitize_user_html_with_iframes(`<iframe src="https://ok.example" referrerpolicy="no-referrer"></iframe>`)
|
|
154
|
+
if (!noref.includes('referrerpolicy="no-referrer"')) fail(`author referrerpolicy not honored: ${noref}`)
|
|
155
|
+
|
|
156
|
+
// `allow` (Permissions-Policy delegation, e.g. YouTube autoplay) preserved
|
|
157
|
+
const allowAttr = sanitize_user_html_with_iframes(`<iframe src="https://ok.example" allow="autoplay; encrypted-media" allowfullscreen></iframe>`)
|
|
158
|
+
if (!allowAttr.includes('allow="autoplay; encrypted-media"')) fail(`allow attribute stripped: ${allowAttr}`)
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------------------------
|
|
161
|
+
// 4. Everything sanitize_user_html preserves must still be preserved
|
|
162
|
+
// ---------------------------------------------------------------------------------------------
|
|
163
|
+
const heading = sanitize_user_html_with_iframes(`<h1>Welcome</h1><h3 style="color:#333">Sub</h3>`)
|
|
164
|
+
if (!(heading.includes('<h1>') && heading.includes('<h3') && heading.toLowerCase().includes('color'))) fail(`headings/style stripped: ${heading}`)
|
|
165
|
+
|
|
166
|
+
const table = sanitize_user_html_with_iframes(`<table><thead><tr><th>H</th></tr></thead><tbody><tr><td style="padding:4px" colspan="2">cell</td></tr></tbody></table>`)
|
|
167
|
+
if (!(table.includes('<table') && table.includes('<td') && table.includes('colspan'))) fail(`table stripped: ${table}`)
|
|
168
|
+
|
|
169
|
+
const list = sanitize_user_html_with_iframes(`<ul><li>a</li></ul><ol start="3"><li>c</li></ol>`)
|
|
170
|
+
if (!(list.includes('<ul') && list.includes('<li') && list.includes('<ol'))) fail(`list stripped: ${list}`)
|
|
171
|
+
|
|
172
|
+
const link = sanitize_user_html_with_iframes(`<a href="https://example.com">link</a>`)
|
|
173
|
+
if (!link.includes('href="https://example.com"')) fail(`safe link stripped: ${link}`)
|
|
174
|
+
if (!link.toLowerCase().includes('noopener')) fail(`external link not hardened: ${link}`)
|
|
175
|
+
|
|
176
|
+
const img = sanitize_user_html_with_iframes(`<img src="https://cdn.example.com/a.png" alt="pic" width="200">`)
|
|
177
|
+
if (!(img.includes('src="https://cdn.example.com/a.png"') && img.includes('alt="pic"'))) fail(`image stripped: ${img}`)
|
|
178
|
+
|
|
179
|
+
const dataimg = sanitize_user_html_with_iframes(`<img src="data:image/png;base64,iVBORw0KGgo=">`)
|
|
180
|
+
if (!dataimg.includes('data:image/png')) fail(`data: image stripped: ${dataimg}`)
|
|
181
|
+
|
|
182
|
+
const fmt = sanitize_user_html_with_iframes(`<p><strong>b</strong> <em>i</em> <span style="font-size:14px">s</span></p><blockquote>q</blockquote>`)
|
|
183
|
+
if (!(fmt.includes('<strong>') && fmt.includes('<span') && fmt.toLowerCase().includes('font-size'))) fail(`formatting stripped: ${fmt}`)
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------------------------
|
|
186
|
+
// 5. The boundary: sanitize_user_html must NOT have been loosened
|
|
187
|
+
// ---------------------------------------------------------------------------------------------
|
|
188
|
+
const shared = sanitize_user_html(`<iframe src="https://www.youtube.com/embed/abc"></iframe>`)
|
|
189
|
+
if (shared.toLowerCase().includes('<iframe')) fail(`sanitize_user_html must still strip iframes: ${shared}`)
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------------------------
|
|
192
|
+
// 6. End to end: an admin's iframe survives templating, a patient-injected one does not
|
|
193
|
+
// ---------------------------------------------------------------------------------------------
|
|
194
|
+
const adminTemplate = `<p>Hi {{enduser.fname}}, watch this:</p><iframe src="https://www.youtube.com/embed/abc"></iframe>`
|
|
195
|
+
const templated = replace_form_field_template_values(adminTemplate, {
|
|
196
|
+
enduser: { fname: `<iframe src="https://evil.example/phish"></iframe>` } as any,
|
|
197
|
+
responses: [],
|
|
198
|
+
escapeNewlinesAsHTMLBreaks: true,
|
|
199
|
+
escapeHTMLValues: true,
|
|
200
|
+
})
|
|
201
|
+
const endToEnd = sanitize_user_html_with_iframes(templated)
|
|
202
|
+
if (!endToEnd.includes('src="https://www.youtube.com/embed/abc"')) fail(`admin's own iframe did not survive: ${endToEnd}`)
|
|
203
|
+
// The patient's markup must survive only as inert escaped text — so 'evil.example' may still
|
|
204
|
+
// appear as visible characters, but there must be exactly one LIVE frame (the admin's).
|
|
205
|
+
if (!endToEnd.includes('<iframe')) fail(`patient markup not escaped to inert text: ${endToEnd}`)
|
|
206
|
+
if ((endToEnd.toLowerCase().match(/<iframe/g) || []).length !== 1) fail(`patient-injected iframe became a live frame: ${endToEnd}`)
|
|
207
|
+
|
|
208
|
+
console.log("✅ sanitize_user_html_with_iframes XSS regression tests passed")
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (require.main === module) {
|
|
212
|
+
sanitize_user_html_with_iframes_tests()
|
|
213
|
+
.then(() => { console.log("✅ suite completed"); process.exit(0) })
|
|
214
|
+
.catch((err) => { console.error("❌ suite failed:", err); process.exit(1) })
|
|
215
|
+
}
|
|
@@ -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
|
-
|
|
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 =>
|
|
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
|
|