@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.
Files changed (59) hide show
  1. package/lib/cjs/sdk.d.ts +2 -2
  2. package/lib/cjs/tests/api_tests/appointment_link_expiration.test.d.ts +6 -0
  3. package/lib/cjs/tests/api_tests/appointment_link_expiration.test.d.ts.map +1 -0
  4. package/lib/cjs/tests/api_tests/appointment_link_expiration.test.js +163 -0
  5. package/lib/cjs/tests/api_tests/appointment_link_expiration.test.js.map +1 -0
  6. package/lib/cjs/tests/api_tests/beluga_trigger_refill.test.d.ts +6 -0
  7. package/lib/cjs/tests/api_tests/beluga_trigger_refill.test.d.ts.map +1 -0
  8. package/lib/cjs/tests/api_tests/beluga_trigger_refill.test.js +224 -0
  9. package/lib/cjs/tests/api_tests/beluga_trigger_refill.test.js.map +1 -0
  10. package/lib/cjs/tests/api_tests/embeddables_auth_token.test.d.ts +6 -0
  11. package/lib/cjs/tests/api_tests/embeddables_auth_token.test.d.ts.map +1 -0
  12. package/lib/cjs/tests/api_tests/embeddables_auth_token.test.js +354 -0
  13. package/lib/cjs/tests/api_tests/embeddables_auth_token.test.js.map +1 -0
  14. package/lib/cjs/tests/api_tests/start_ai_conversation.test.d.ts +6 -0
  15. package/lib/cjs/tests/api_tests/start_ai_conversation.test.d.ts.map +1 -0
  16. package/lib/cjs/tests/api_tests/start_ai_conversation.test.js +351 -0
  17. package/lib/cjs/tests/api_tests/start_ai_conversation.test.js.map +1 -0
  18. package/lib/cjs/tests/tests.d.ts.map +1 -1
  19. package/lib/cjs/tests/tests.js +217 -189
  20. package/lib/cjs/tests/tests.js.map +1 -1
  21. package/lib/cjs/tests/unit_tests/phone_validation.test.d.ts +3 -0
  22. package/lib/cjs/tests/unit_tests/phone_validation.test.d.ts.map +1 -0
  23. package/lib/cjs/tests/unit_tests/phone_validation.test.js +96 -0
  24. package/lib/cjs/tests/unit_tests/phone_validation.test.js.map +1 -0
  25. package/lib/esm/sdk.d.ts +2 -2
  26. package/lib/esm/tests/api_tests/appointment_link_expiration.test.d.ts +6 -0
  27. package/lib/esm/tests/api_tests/appointment_link_expiration.test.d.ts.map +1 -0
  28. package/lib/esm/tests/api_tests/appointment_link_expiration.test.js +156 -0
  29. package/lib/esm/tests/api_tests/appointment_link_expiration.test.js.map +1 -0
  30. package/lib/esm/tests/api_tests/beluga_trigger_refill.test.d.ts +6 -0
  31. package/lib/esm/tests/api_tests/beluga_trigger_refill.test.d.ts.map +1 -0
  32. package/lib/esm/tests/api_tests/beluga_trigger_refill.test.js +220 -0
  33. package/lib/esm/tests/api_tests/beluga_trigger_refill.test.js.map +1 -0
  34. package/lib/esm/tests/api_tests/embeddables_auth_token.test.d.ts +6 -0
  35. package/lib/esm/tests/api_tests/embeddables_auth_token.test.d.ts.map +1 -0
  36. package/lib/esm/tests/api_tests/embeddables_auth_token.test.js +347 -0
  37. package/lib/esm/tests/api_tests/embeddables_auth_token.test.js.map +1 -0
  38. package/lib/esm/tests/api_tests/start_ai_conversation.test.d.ts +6 -0
  39. package/lib/esm/tests/api_tests/start_ai_conversation.test.d.ts.map +1 -0
  40. package/lib/esm/tests/api_tests/start_ai_conversation.test.js +347 -0
  41. package/lib/esm/tests/api_tests/start_ai_conversation.test.js.map +1 -0
  42. package/lib/esm/tests/tests.d.ts.map +1 -1
  43. package/lib/esm/tests/tests.js +217 -189
  44. package/lib/esm/tests/tests.js.map +1 -1
  45. package/lib/esm/tests/unit_tests/phone_validation.test.d.ts +3 -0
  46. package/lib/esm/tests/unit_tests/phone_validation.test.d.ts.map +1 -0
  47. package/lib/esm/tests/unit_tests/phone_validation.test.js +93 -0
  48. package/lib/esm/tests/unit_tests/phone_validation.test.js.map +1 -0
  49. package/lib/tsconfig.tsbuildinfo +1 -1
  50. package/package.json +10 -10
  51. package/src/tests/api_tests/appointment_link_expiration.test.ts +92 -0
  52. package/src/tests/api_tests/beluga_trigger_refill.test.ts +152 -0
  53. package/src/tests/api_tests/embeddables_auth_token.test.ts +417 -0
  54. package/src/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.ts +292 -0
  55. package/src/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.ts +179 -0
  56. package/src/tests/api_tests/start_ai_conversation.test.ts +256 -0
  57. package/src/tests/tests.ts +14 -0
  58. package/src/tests/unit_tests/phone_validation.test.ts +108 -0
  59. 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
+ }