@tellescope/sdk 1.255.19 → 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/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/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/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-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +524 -294
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -1
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +431 -230
- 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 +2 -3
- package/lib/esm/sdk.d.ts.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/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/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/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-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +524 -294
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -1
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +431 -230
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/tests/api_tests/custom_dashboards.test.ts +92 -0
- package/src/tests/api_tests/file_download_unicode_names.test.ts +223 -0
- 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/security/F-0013-sanitize-user-html-with-iframes.test.ts +215 -0
- package/src/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.ts +215 -0
- package/src/tests/tests.ts +212 -1
- package/test_generated.pdf +0 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
}
|