@tellescope/sdk 1.256.0 → 1.256.2
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 +2 -2
- package/lib/cjs/tests/api_tests/appointment_link_expiration.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/appointment_link_expiration.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/appointment_link_expiration.test.js +163 -0
- package/lib/cjs/tests/api_tests/appointment_link_expiration.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/beluga_trigger_refill.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/beluga_trigger_refill.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/beluga_trigger_refill.test.js +224 -0
- package/lib/cjs/tests/api_tests/beluga_trigger_refill.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/embeddables_auth_token.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/embeddables_auth_token.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/embeddables_auth_token.test.js +354 -0
- package/lib/cjs/tests/api_tests/embeddables_auth_token.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/start_ai_conversation.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/start_ai_conversation.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/start_ai_conversation.test.js +351 -0
- package/lib/cjs/tests/api_tests/start_ai_conversation.test.js.map +1 -0
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +217 -189
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/cjs/tests/unit_tests/phone_validation.test.d.ts +3 -0
- package/lib/cjs/tests/unit_tests/phone_validation.test.d.ts.map +1 -0
- package/lib/cjs/tests/unit_tests/phone_validation.test.js +96 -0
- package/lib/cjs/tests/unit_tests/phone_validation.test.js.map +1 -0
- package/lib/esm/sdk.d.ts +2 -2
- package/lib/esm/tests/api_tests/appointment_link_expiration.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/appointment_link_expiration.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/appointment_link_expiration.test.js +156 -0
- package/lib/esm/tests/api_tests/appointment_link_expiration.test.js.map +1 -0
- package/lib/esm/tests/api_tests/beluga_trigger_refill.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/beluga_trigger_refill.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/beluga_trigger_refill.test.js +220 -0
- package/lib/esm/tests/api_tests/beluga_trigger_refill.test.js.map +1 -0
- package/lib/esm/tests/api_tests/embeddables_auth_token.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/embeddables_auth_token.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/embeddables_auth_token.test.js +347 -0
- package/lib/esm/tests/api_tests/embeddables_auth_token.test.js.map +1 -0
- package/lib/esm/tests/api_tests/start_ai_conversation.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/start_ai_conversation.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/start_ai_conversation.test.js +347 -0
- package/lib/esm/tests/api_tests/start_ai_conversation.test.js.map +1 -0
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +217 -189
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/esm/tests/unit_tests/phone_validation.test.d.ts +3 -0
- package/lib/esm/tests/unit_tests/phone_validation.test.d.ts.map +1 -0
- package/lib/esm/tests/unit_tests/phone_validation.test.js +93 -0
- package/lib/esm/tests/unit_tests/phone_validation.test.js.map +1 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/tests/api_tests/appointment_link_expiration.test.ts +92 -0
- package/src/tests/api_tests/beluga_trigger_refill.test.ts +152 -0
- package/src/tests/api_tests/embeddables_auth_token.test.ts +417 -0
- package/src/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.ts +292 -0
- package/src/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.ts +179 -0
- package/src/tests/api_tests/start_ai_conversation.test.ts +256 -0
- package/src/tests/tests.ts +14 -0
- package/src/tests/unit_tests/phone_validation.test.ts +108 -0
- package/test_generated.pdf +0 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import axios from "axios"
|
|
4
|
+
|
|
5
|
+
import { Session, EnduserSession } 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
|
+
* {{enduser.embeddablesAuthToken}} — a 24h, path-confined enduser token that can be spliced into a
|
|
16
|
+
* third-party URL by a message author.
|
|
17
|
+
*
|
|
18
|
+
* The Embeddables handoff: Tellescope messages a patient a link to an Embeddables-hosted page with an
|
|
19
|
+
* enduser token in the URL; that page reads the patient's own record with it, refreshes it on load, and
|
|
20
|
+
* falls back to the existing OTP flow once it has expired.
|
|
21
|
+
*
|
|
22
|
+
* Everything here renders through POST /v1/templated-message, which runs the real
|
|
23
|
+
* process_message -> getTemplatedData path — the same code a real send uses, including the
|
|
24
|
+
* login_enduser minting branch. No messages are sent.
|
|
25
|
+
*
|
|
26
|
+
* Two facts the assertions rest on:
|
|
27
|
+
* - a BARE url/token is not shortened (only the {url}[text] syntax becomes a /r/<token> redirect), so
|
|
28
|
+
* the raw JWT lands in `plaintext` and is directly readable — unlike {{portal.link...}}, whose token
|
|
29
|
+
* the shortener swallows.
|
|
30
|
+
* - decoding needs no dependency: the test asserts on CLAIMS, never on signature validity.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const decodeJwt = (jwt: string) => {
|
|
34
|
+
const [, payload] = jwt.split('.')
|
|
35
|
+
if (!payload) throw new Error(`not a JWT: ${jwt}`)
|
|
36
|
+
return JSON.parse(Buffer.from(payload, 'base64url').toString()) as {
|
|
37
|
+
id: string,
|
|
38
|
+
type: string,
|
|
39
|
+
businessId: string,
|
|
40
|
+
sessionScopes?: string[],
|
|
41
|
+
iat: number,
|
|
42
|
+
exp: number,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const looksLikeJwt = (s: string) => s.split('.').length === 3 && s.split('.').every(p => !!p)
|
|
47
|
+
|
|
48
|
+
const SECONDS_IN_DAY = 86400
|
|
49
|
+
|
|
50
|
+
type ProbeMethod = 'get' | 'post' | 'patch' | 'delete'
|
|
51
|
+
|
|
52
|
+
export const embeddables_auth_token_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
53
|
+
log_header("Embeddables Auth Token Merge Field Tests")
|
|
54
|
+
|
|
55
|
+
const businessId = sdk.userInfo.businessId
|
|
56
|
+
|
|
57
|
+
// Probe with raw axios rather than the SDK: parseError (sdk/src/session.ts) discards the status code,
|
|
58
|
+
// and a confinement denial (403 Inaccessible) has to be told apart from a 401 or a permissions error.
|
|
59
|
+
const request = async (token: string, method: ProbeMethod, path: string, data?: any) => {
|
|
60
|
+
const r = await axios.request({
|
|
61
|
+
method,
|
|
62
|
+
url: `${host}/v1${path}`,
|
|
63
|
+
...method === 'get' ? { params: data } : { data },
|
|
64
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
65
|
+
validateStatus: () => true,
|
|
66
|
+
})
|
|
67
|
+
return { status: r.status, body: r.data }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const enduser = await sdk.api.endusers.createOne({
|
|
71
|
+
fname: 'Embeddables',
|
|
72
|
+
lname: 'Patient',
|
|
73
|
+
email: `embeddables-token-${Date.now()}@tellescope.com`,
|
|
74
|
+
phone: '+15555555555',
|
|
75
|
+
})
|
|
76
|
+
const otherEnduser = await sdk.api.endusers.createOne({
|
|
77
|
+
fname: 'Other',
|
|
78
|
+
lname: 'Patient',
|
|
79
|
+
email: `embeddables-other-${Date.now()}@tellescope.com`,
|
|
80
|
+
dateOfBirth: '01-01-1990',
|
|
81
|
+
})
|
|
82
|
+
// a tenant custom field with the same name as the merge field, to prove it cannot shadow it
|
|
83
|
+
const collisionEnduser = await sdk.api.endusers.createOne({
|
|
84
|
+
fname: 'Collision',
|
|
85
|
+
fields: { embeddablesAuthToken: 'SHOULD_NOT_APPEAR' },
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
/* ------------------------------------------------------------------ *
|
|
90
|
+
* 5a. Minting and TTL, through the real templating pipeline
|
|
91
|
+
* ------------------------------------------------------------------ */
|
|
92
|
+
|
|
93
|
+
const render = async (message: string, o?: { enduserId?: string, caller?: Session }) => {
|
|
94
|
+
const caller = o?.caller ?? sdk
|
|
95
|
+
const r = await caller.api.templates.get_templated_message({
|
|
96
|
+
message,
|
|
97
|
+
enduserId: o?.enduserId ?? enduser.id,
|
|
98
|
+
userId: caller.userInfo.id,
|
|
99
|
+
channel: 'Chat',
|
|
100
|
+
})
|
|
101
|
+
return r.plaintext
|
|
102
|
+
}
|
|
103
|
+
const renderToken = async (message: string, o?: { enduserId?: string, caller?: Session }) => (
|
|
104
|
+
decodeJwt(await render(message, o))
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const defaultToken = await renderToken('{{enduser.embeddablesAuthToken}}')
|
|
108
|
+
assert(
|
|
109
|
+
defaultToken.type === 'enduser',
|
|
110
|
+
`token type is '${defaultToken.type}', expected 'enduser'`,
|
|
111
|
+
'merge field mints an enduser token',
|
|
112
|
+
)
|
|
113
|
+
assert(
|
|
114
|
+
defaultToken.id === enduser.id,
|
|
115
|
+
`token id is '${defaultToken.id}', expected '${enduser.id}'`,
|
|
116
|
+
'token is bound to the templated enduser',
|
|
117
|
+
)
|
|
118
|
+
assert(
|
|
119
|
+
defaultToken.businessId === businessId,
|
|
120
|
+
`token businessId is '${defaultToken.businessId}', expected '${businessId}'`,
|
|
121
|
+
'token carries the correct businessId',
|
|
122
|
+
)
|
|
123
|
+
assert(
|
|
124
|
+
JSON.stringify(defaultToken.sessionScopes) === JSON.stringify(['embeddables-token']),
|
|
125
|
+
`sessionScopes is ${JSON.stringify(defaultToken.sessionScopes)}, expected ["embeddables-token"]`,
|
|
126
|
+
'token is confined to exactly the embeddables-token scope',
|
|
127
|
+
)
|
|
128
|
+
assert(
|
|
129
|
+
defaultToken.exp - defaultToken.iat === SECONDS_IN_DAY,
|
|
130
|
+
`default TTL is ${defaultToken.exp - defaultToken.iat}s, expected ${SECONDS_IN_DAY}s`,
|
|
131
|
+
'default TTL is 24h, matching {{portal.link...}}',
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
const shortToken = await renderToken('{{enduser.embeddablesAuthToken:3600}}')
|
|
135
|
+
assert(
|
|
136
|
+
shortToken.exp - shortToken.iat === 3600,
|
|
137
|
+
`configured TTL is ${shortToken.exp - shortToken.iat}s, expected 3600s`,
|
|
138
|
+
'a caller-specified TTL is honored',
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
// At MINT TIME only. The scope grants refresh, and refresh mints max(remaining, 4h) with no absolute
|
|
142
|
+
// ceiling, so a sub-4h TTL is floored to 4h on first refresh and the session can slide indefinitely.
|
|
143
|
+
// Inherited from how every enduser link token works (including the 24h {{portal.link}}) — see the
|
|
144
|
+
// comment at the cap in message_templating.ts. This asserts the cap, not a lifetime guarantee.
|
|
145
|
+
const cappedToken = await renderToken('{{enduser.embeddablesAuthToken:999999}}')
|
|
146
|
+
assert(
|
|
147
|
+
cappedToken.exp - cappedToken.iat === SECONDS_IN_DAY,
|
|
148
|
+
`capped TTL is ${cappedToken.exp - cappedToken.iat}s, expected ${SECONDS_IN_DAY}s`,
|
|
149
|
+
'the 24h cap holds — the minted TTL is configurable downward only',
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
// garbage must fall back to the default: not NaN, not a thrown error, not an empty string
|
|
153
|
+
for (const ttl of ['abc', '-5', '0']) {
|
|
154
|
+
const fallback = await renderToken(`{{enduser.embeddablesAuthToken:${ttl}}}`)
|
|
155
|
+
assert(
|
|
156
|
+
fallback.exp - fallback.iat === SECONDS_IN_DAY,
|
|
157
|
+
`TTL ':${ttl}' produced ${fallback.exp - fallback.iat}s, expected a ${SECONDS_IN_DAY}s fallback`,
|
|
158
|
+
`an unparseable TTL (':${ttl}') falls back to 24h`,
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// the literal Embeddables use case: the token spliced into a third-party URL
|
|
163
|
+
const inURL = await render('https://example.com/?token={{enduser.embeddablesAuthToken}}')
|
|
164
|
+
const urlPrefix = 'https://example.com/?token='
|
|
165
|
+
assert(
|
|
166
|
+
inURL.startsWith(urlPrefix),
|
|
167
|
+
`rendered URL was '${inURL}'`,
|
|
168
|
+
'the token substitutes inline into an arbitrary URL',
|
|
169
|
+
)
|
|
170
|
+
assert(
|
|
171
|
+
!inURL.includes('/r/'),
|
|
172
|
+
`rendered URL was shortened: '${inURL}'`,
|
|
173
|
+
'a bare URL is not run through the link shortener',
|
|
174
|
+
)
|
|
175
|
+
assert(
|
|
176
|
+
decodeJwt(inURL.slice(urlPrefix.length)).id === enduser.id,
|
|
177
|
+
'the JWT spliced into the URL did not decode to this enduser',
|
|
178
|
+
'the JWT survives intact inside the URL',
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
const collisionRendered = await render(
|
|
182
|
+
'{{enduser.embeddablesAuthToken}}',
|
|
183
|
+
{ enduserId: collisionEnduser.id },
|
|
184
|
+
)
|
|
185
|
+
assert(
|
|
186
|
+
!collisionRendered.includes('SHOULD_NOT_APPEAR') && looksLikeJwt(collisionRendered),
|
|
187
|
+
`custom field shadowed the merge field: '${collisionRendered}'`,
|
|
188
|
+
'a tenant custom field named embeddablesAuthToken cannot shadow the merge field',
|
|
189
|
+
)
|
|
190
|
+
assert(
|
|
191
|
+
decodeJwt(collisionRendered).id === collisionEnduser.id,
|
|
192
|
+
'the collision-guard render did not decode to a JWT for that enduser',
|
|
193
|
+
'the collision-guard render is a real token',
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
// the new field.split(':') runs on EVERY enduser merge field — a regression there would silently
|
|
197
|
+
// truncate ordinary field names, so pin that they still resolve
|
|
198
|
+
const mixed = await render('{{enduser.fname}}|{{enduser.embeddablesAuthToken}}')
|
|
199
|
+
const [renderedFname, mixedToken] = mixed.split('|')
|
|
200
|
+
assert(
|
|
201
|
+
renderedFname === 'Embeddables',
|
|
202
|
+
`expected fname 'Embeddables', got '${renderedFname}'`,
|
|
203
|
+
'ordinary enduser merge fields still resolve alongside the new one',
|
|
204
|
+
)
|
|
205
|
+
assert(
|
|
206
|
+
looksLikeJwt(mixedToken ?? ''),
|
|
207
|
+
`expected a JWT after the fname, got '${mixedToken}'`,
|
|
208
|
+
'the token still renders when combined with other fields',
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
/* ------------------------------------------------------------------ *
|
|
212
|
+
* 5b. The scope grants exactly what it should
|
|
213
|
+
* ------------------------------------------------------------------ */
|
|
214
|
+
|
|
215
|
+
const scopedAuthToken = await render('{{enduser.embeddablesAuthToken}}')
|
|
216
|
+
const scopedSession = new EnduserSession({ host, businessId, authToken: scopedAuthToken })
|
|
217
|
+
|
|
218
|
+
const ownRead = await request(scopedAuthToken, 'get', `/enduser/${enduser.id}`)
|
|
219
|
+
assert(
|
|
220
|
+
ownRead.status === 200 && ownRead.body?.id === enduser.id,
|
|
221
|
+
`own-record read returned ${ownRead.status}: ${JSON.stringify(ownRead.body)}`,
|
|
222
|
+
'the token reads its own enduser record (GET /enduser/:id)',
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
// getOne(id) is the singular read the SDK's findById routes to. NOT the bulk path —
|
|
226
|
+
// findById(id, { batch: true }) hits /endusers/bulk-read, a different grant that is deliberately
|
|
227
|
+
// outside this scope (see the trap documented at session_scopes.ts:33-38)
|
|
228
|
+
const ownReadViaSDK = await scopedSession.api.endusers.getOne(enduser.id)
|
|
229
|
+
assert(
|
|
230
|
+
ownReadViaSDK?.id === enduser.id,
|
|
231
|
+
`SDK getOne returned ${JSON.stringify(ownReadViaSDK)}`,
|
|
232
|
+
'the SDK singular-read path works with the token',
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
const bulkRead = await request(scopedAuthToken, 'post', '/endusers/bulk-read', { ids: [enduser.id] })
|
|
236
|
+
assert(
|
|
237
|
+
bulkRead.status === 403,
|
|
238
|
+
`bulk-read returned ${bulkRead.status}, expected 403`,
|
|
239
|
+
'findById({ batch: true }) is NOT granted — bulk-read is a separate route',
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
// ESCALATION CHECK. If the scope dropped on refresh, a 24h URL token would upgrade itself into a
|
|
243
|
+
// full portal session and the entire design would leak.
|
|
244
|
+
const refreshed = await scopedSession.refresh_session()
|
|
245
|
+
const refreshedClaims = decodeJwt(refreshed.authToken)
|
|
246
|
+
assert(
|
|
247
|
+
JSON.stringify(refreshedClaims.sessionScopes) === JSON.stringify(['embeddables-token']),
|
|
248
|
+
`refreshed token sessionScopes is ${JSON.stringify(refreshedClaims.sessionScopes)}`,
|
|
249
|
+
'refresh_session preserves the confinement — no escalation via refresh',
|
|
250
|
+
)
|
|
251
|
+
assert(
|
|
252
|
+
refreshedClaims.id === enduser.id,
|
|
253
|
+
`refreshed token id is '${refreshedClaims.id}'`,
|
|
254
|
+
'refresh_session returns a token for the same enduser',
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
// Each entry pins the status of the layer that actually stops it, because two different layers do.
|
|
258
|
+
// 403 Inaccessible is the scope confinement (checkAccess -> isPathAllowed) and is what everything an
|
|
259
|
+
// enduser session type can reach at all returns. load-inbox-data is staff-only — it has no entry in
|
|
260
|
+
// endusers.enduserActions, so its checkAccess is built with allowEnduser: false (routing.ts:913) and
|
|
261
|
+
// an enduser token is rejected as unauthenticated before the confinement is ever consulted. That 401
|
|
262
|
+
// is a stricter denial, not a weaker one; it is pinned as 401 so that a later change making the route
|
|
263
|
+
// enduser-reachable surfaces here rather than passing silently on the confinement fallback.
|
|
264
|
+
const denied: [string, ProbeMethod, string, any, number][] = [
|
|
265
|
+
// deliberately ungranted: it returns only the decoded token the caller already holds. Asserting
|
|
266
|
+
// the 403 means a future widening has to be a conscious edit.
|
|
267
|
+
['current_session_info', 'get', '/enduser-session-info', undefined, 403],
|
|
268
|
+
// no write grant at all — and email/phone are login channels (password reset resolves by email,
|
|
269
|
+
// OTP delivers to phone), so specifically confirm they cannot be repointed
|
|
270
|
+
['updateOne (fname)', 'patch', `/enduser/${enduser.id}`, { updates: { fname: 'Hacked' } }, 403],
|
|
271
|
+
['updateOne (email)', 'patch', `/enduser/${enduser.id}`, { updates: { email: 'attacker@example.com' } }, 403],
|
|
272
|
+
['updateOne (phone)', 'patch', `/enduser/${enduser.id}`, { updates: { phone: '+15550001111' } }, 403],
|
|
273
|
+
['chat_rooms.getSome', 'get', '/chat-rooms', undefined, 403],
|
|
274
|
+
['load_inbox_data', 'get', '/endusers/load-inbox-data', { limit: 10 }, 401],
|
|
275
|
+
['endusers.getSome (readMany)', 'get', '/endusers', undefined, 403],
|
|
276
|
+
// THE FILTER ORACLE. The bare singular-read route is the same handler as /enduser/:id, but with no
|
|
277
|
+
// path id it evaluates a caller-supplied filter against UNREDACTED records and answers 200 on match
|
|
278
|
+
// / 400 on miss — a per-field probe over every enduser in the business (applyRedactions strips the
|
|
279
|
+
// response body, never the filter). Both syntaxes are asserted because dropping mdbFilter alone
|
|
280
|
+
// would not close it: `filter`'s _gt/_lt/_in operators binary-search a value just as well. A 403
|
|
281
|
+
// here means the route is unreachable, not that one filter shape was rejected.
|
|
282
|
+
['bare read (filter oracle)', 'get', '/enduser', { filter: { fname: 'Other' } }, 403],
|
|
283
|
+
['bare read (mdbFilter oracle)', 'get', '/enduser', { mdbFilter: { fname: { $regex: '^Oth' } } }, 403],
|
|
284
|
+
]
|
|
285
|
+
for (const [label, method, path, data, expected] of denied) {
|
|
286
|
+
const r = await request(scopedAuthToken, method, path, data)
|
|
287
|
+
assert(
|
|
288
|
+
r.status === expected,
|
|
289
|
+
`${label} returned ${r.status}: ${JSON.stringify(r.body)} — expected ${expected}`,
|
|
290
|
+
`denied: ${label} (${expected})`,
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// the write really did not land
|
|
295
|
+
const afterWriteAttempts = await sdk.api.endusers.getOne(enduser.id)
|
|
296
|
+
assert(
|
|
297
|
+
afterWriteAttempts.fname === 'Embeddables' && afterWriteAttempts.phone === '+15555555555',
|
|
298
|
+
`record was mutated: ${JSON.stringify({ fname: afterWriteAttempts.fname, phone: afterWriteAttempts.phone })}`,
|
|
299
|
+
'no denied write reached the record',
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
// CROSS-PATIENT READ. get:/enduser/:id cannot be pinned (the id lives in the token, not in
|
|
303
|
+
// scopeContext), so this is route-allowed and will not 403. What stops it is applyRedactions
|
|
304
|
+
// reducing a foreign enduser to { id, displayName, unredactedFields, unredactedTags }. This is the
|
|
305
|
+
// PHI-egress assertion — the one place the :id wildcard could bite.
|
|
306
|
+
const crossRead = await request(scopedAuthToken, 'get', `/enduser/${otherEnduser.id}`)
|
|
307
|
+
const leaked = ['email', 'phone', 'dateOfBirth', 'fields', 'fname', 'lname']
|
|
308
|
+
.filter(k => crossRead.status === 200 && crossRead.body?.[k] !== undefined)
|
|
309
|
+
assert(
|
|
310
|
+
leaked.length === 0,
|
|
311
|
+
`cross-patient read (status ${crossRead.status}) leaked: ${leaked.join(', ')}`,
|
|
312
|
+
'a cross-patient read is reduced to non-PHI display fields',
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
/* ------------------------------------------------------------------ *
|
|
316
|
+
* 5c. Invalidation behaves as recommended to Embeddables
|
|
317
|
+
*
|
|
318
|
+
* They were told to burn the URL token on page load via
|
|
319
|
+
* refresh_session({ invalidatePreviousToken: true }), so pin that contract.
|
|
320
|
+
* ------------------------------------------------------------------ */
|
|
321
|
+
|
|
322
|
+
const tokenA = await render('{{enduser.embeddablesAuthToken}}')
|
|
323
|
+
const sessionA = new EnduserSession({ host, businessId, authToken: tokenA })
|
|
324
|
+
|
|
325
|
+
const preBurn = await request(tokenA, 'get', `/enduser/${enduser.id}`)
|
|
326
|
+
assert(preBurn.status === 200, `token A read returned ${preBurn.status}`, 'token A reads its own record before the burn')
|
|
327
|
+
|
|
328
|
+
// a separately-minted session for the same enduser — the control proving the blocklist is
|
|
329
|
+
// per-credential and not a blanket logout that would kick the patient out of the portal
|
|
330
|
+
const { authToken: portalToken } = await sdk.api.endusers.generate_auth_token({ id: enduser.id })
|
|
331
|
+
|
|
332
|
+
const { authToken: tokenB } = await sessionA.refresh_session({ invalidatePreviousToken: true })
|
|
333
|
+
|
|
334
|
+
const postBurnA = await request(tokenA, 'get', `/enduser/${enduser.id}`)
|
|
335
|
+
assert(
|
|
336
|
+
postBurnA.status === 401,
|
|
337
|
+
`token A returned ${postBurnA.status} after the burn, expected 401`,
|
|
338
|
+
'invalidatePreviousToken blocklists token A (401)',
|
|
339
|
+
)
|
|
340
|
+
// note: the body here is plain text 'Unauthenticated', not JSON — do not JSON.parse it
|
|
341
|
+
|
|
342
|
+
const postBurnB = await request(tokenB, 'get', `/enduser/${enduser.id}`)
|
|
343
|
+
assert(
|
|
344
|
+
postBurnB.status === 200,
|
|
345
|
+
`token B returned ${postBurnB.status} after the burn, expected 200`,
|
|
346
|
+
'token B still works after the burn',
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
const postBurnPortal = await request(portalToken, 'get', `/enduser/${enduser.id}`)
|
|
350
|
+
assert(
|
|
351
|
+
postBurnPortal.status === 200,
|
|
352
|
+
`separately-minted session returned ${postBurnPortal.status}, expected 200`,
|
|
353
|
+
'the burn is a per-token blocklist, not a blanket logout of the patient',
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
/* ------------------------------------------------------------------ *
|
|
357
|
+
* 5d. The OTP fallback is still reachable from a dead token
|
|
358
|
+
*
|
|
359
|
+
* The whole Embeddables fallback rests on this: otp-methods only jwtDecodes the token and never
|
|
360
|
+
* verifies it, so expiry and invalidation are both irrelevant to it. Read-only, sends nothing.
|
|
361
|
+
*
|
|
362
|
+
* Deliberately not covered: send-otp-code / verify-otp-code. send_otp dispatches a real email/SMS,
|
|
363
|
+
* so it stays out of the automated suite — and neither endpoint is changed by this work.
|
|
364
|
+
* ------------------------------------------------------------------ */
|
|
365
|
+
|
|
366
|
+
const otpMethods = await new EnduserSession({ host, businessId }).api.endusers.get_otp_methods({ token: tokenA })
|
|
367
|
+
assert(
|
|
368
|
+
otpMethods.methods.includes('email') && otpMethods.methods.includes('sms'),
|
|
369
|
+
`otp methods for an invalidated token: ${JSON.stringify(otpMethods.methods)}`,
|
|
370
|
+
'otp-methods still resolves from an invalidated token (the OTP fallback stays reachable)',
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
/* ------------------------------------------------------------------ *
|
|
374
|
+
* 5e. Access control on rendering
|
|
375
|
+
*
|
|
376
|
+
* get_templated_message is not admin-gated; it requires templates.read + endusers.read, hand-checked
|
|
377
|
+
* in its handler. A non-admin rendering this field is INTENTIONAL — any staff user who can already
|
|
378
|
+
* render {{portal.link...}} through this endpoint can already mint a patient token, so the merge
|
|
379
|
+
* field adds no new privilege. Asserted so that decision stays visible rather than implicit.
|
|
380
|
+
* ------------------------------------------------------------------ */
|
|
381
|
+
|
|
382
|
+
const nonAdminToken = await renderToken('{{enduser.embeddablesAuthToken}}', { caller: sdkNonAdmin })
|
|
383
|
+
assert(
|
|
384
|
+
nonAdminToken.id === enduser.id
|
|
385
|
+
&& JSON.stringify(nonAdminToken.sessionScopes) === JSON.stringify(['embeddables-token']),
|
|
386
|
+
`non-admin render produced ${JSON.stringify(nonAdminToken)}`,
|
|
387
|
+
'a non-admin with templates.read + endusers.read can render the field (intentional)',
|
|
388
|
+
)
|
|
389
|
+
} finally {
|
|
390
|
+
await Promise.all([
|
|
391
|
+
sdk.api.endusers.deleteOne(enduser.id).catch(() => {}),
|
|
392
|
+
sdk.api.endusers.deleteOne(otherEnduser.id).catch(() => {}),
|
|
393
|
+
sdk.api.endusers.deleteOne(collisionEnduser.id).catch(() => {}),
|
|
394
|
+
])
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Allow running this test file independently
|
|
399
|
+
if (require.main === module) {
|
|
400
|
+
const sdk = new Session({ host })
|
|
401
|
+
const sdkNonAdmin = new Session({ host })
|
|
402
|
+
|
|
403
|
+
const runTests = async () => {
|
|
404
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
405
|
+
await embeddables_auth_token_tests({ sdk, sdkNonAdmin })
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
runTests()
|
|
409
|
+
.then(() => {
|
|
410
|
+
console.log("✅ Embeddables auth token test suite completed successfully")
|
|
411
|
+
process.exit(0)
|
|
412
|
+
})
|
|
413
|
+
.catch((error) => {
|
|
414
|
+
console.error("❌ Embeddables auth token test suite failed:", error)
|
|
415
|
+
process.exit(1)
|
|
416
|
+
})
|
|
417
|
+
}
|