@tellescope/sdk 1.249.0 → 1.249.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 (31) hide show
  1. package/lib/cjs/enduser.d.ts +12 -2
  2. package/lib/cjs/enduser.d.ts.map +1 -1
  3. package/lib/cjs/enduser.js +11 -0
  4. package/lib/cjs/enduser.js.map +1 -1
  5. package/lib/cjs/tests/api_tests/enduser_cross_access_isolation.test.d.ts +6 -0
  6. package/lib/cjs/tests/api_tests/enduser_cross_access_isolation.test.d.ts.map +1 -0
  7. package/lib/cjs/tests/api_tests/enduser_cross_access_isolation.test.js +782 -0
  8. package/lib/cjs/tests/api_tests/enduser_cross_access_isolation.test.js.map +1 -0
  9. package/lib/cjs/tests/tests.d.ts.map +1 -1
  10. package/lib/cjs/tests/tests.js +182 -143
  11. package/lib/cjs/tests/tests.js.map +1 -1
  12. package/lib/esm/enduser.d.ts +12 -2
  13. package/lib/esm/enduser.d.ts.map +1 -1
  14. package/lib/esm/enduser.js +11 -0
  15. package/lib/esm/enduser.js.map +1 -1
  16. package/lib/esm/sdk.d.ts +2 -2
  17. package/lib/esm/session.d.ts +0 -1
  18. package/lib/esm/session.d.ts.map +1 -1
  19. package/lib/esm/tests/api_tests/enduser_cross_access_isolation.test.d.ts +6 -0
  20. package/lib/esm/tests/api_tests/enduser_cross_access_isolation.test.d.ts.map +1 -0
  21. package/lib/esm/tests/api_tests/enduser_cross_access_isolation.test.js +778 -0
  22. package/lib/esm/tests/api_tests/enduser_cross_access_isolation.test.js.map +1 -0
  23. package/lib/esm/tests/tests.d.ts.map +1 -1
  24. package/lib/esm/tests/tests.js +183 -144
  25. package/lib/esm/tests/tests.js.map +1 -1
  26. package/lib/tsconfig.tsbuildinfo +1 -1
  27. package/package.json +10 -10
  28. package/src/enduser.ts +8 -2
  29. package/src/tests/api_tests/enduser_cross_access_isolation.test.ts +542 -0
  30. package/src/tests/tests.ts +89 -7
  31. package/test_generated.pdf +0 -0
@@ -0,0 +1,542 @@
1
+ require('source-map-support').install();
2
+
3
+ import { Session, EnduserSession } from "../../sdk"
4
+ import {
5
+ assert,
6
+ async_test,
7
+ handleAnyError,
8
+ log_header,
9
+ } from "@tellescope/testing"
10
+ import { schema } from "@tellescope/schema"
11
+ import { ModelName } from "@tellescope/types-models"
12
+ import { setup_tests } from "../setup"
13
+
14
+ const host = process.env.API_URL || 'http://localhost:8080' as const
15
+ const businessId = '60398b1131a295e64f084ff6'
16
+
17
+ type OwnerField = 'enduserId' | 'enduserIds' | 'attendees.id'
18
+
19
+ type ModelSetupResult = {
20
+ payloadOverride?: Record<string, any>
21
+ cleanup: () => Promise<void>
22
+ }
23
+
24
+ type ModelCase = {
25
+ model: string
26
+ ownerField: OwnerField
27
+ buildPayload: (enduserBId: string) => Record<string, any>
28
+ setup?: (sdk: Session, enduserBId: string) => Promise<ModelSetupResult>
29
+ }
30
+
31
+ const buildAttendees = (enduserId: string) => [{ id: enduserId, type: 'enduser' as const }]
32
+
33
+ // Per-model coverage of every enduser-scoped read / ownership-mutation endpoint.
34
+ // New enduser-scoped models added with a FilterAccessConstraint on enduserId,
35
+ // enduserIds, or attendees.id MUST be added here (or to EXEMPT_MODELS) — the
36
+ // schema drift guard below will fail the test until coverage is added.
37
+ const MODEL_CASES: ModelCase[] = [
38
+ {
39
+ model: 'tickets',
40
+ ownerField: 'enduserId',
41
+ buildPayload: (enduserBId) => ({ enduserId: enduserBId, title: 'isolation: ticket' }),
42
+ },
43
+ {
44
+ model: 'engagement_events',
45
+ ownerField: 'enduserId',
46
+ buildPayload: (enduserBId) => ({ enduserId: enduserBId, type: 'isolation', significance: 1 }),
47
+ },
48
+ {
49
+ model: 'enduser_observations',
50
+ ownerField: 'enduserId',
51
+ buildPayload: (enduserBId) => ({
52
+ enduserId: enduserBId,
53
+ status: 'final',
54
+ category: 'vital-signs',
55
+ measurement: { unit: 'mmHg', value: 120 },
56
+ }),
57
+ },
58
+ {
59
+ model: 'enduser_tasks',
60
+ ownerField: 'enduserId',
61
+ buildPayload: (enduserBId) => ({ enduserId: enduserBId, title: 'isolation: task' }),
62
+ },
63
+ {
64
+ model: 'care_plans',
65
+ ownerField: 'enduserId',
66
+ buildPayload: (enduserBId) => ({ enduserId: enduserBId, title: 'isolation: care plan' }),
67
+ },
68
+ {
69
+ model: 'enduser_medications',
70
+ ownerField: 'enduserId',
71
+ buildPayload: (enduserBId) => ({ enduserId: enduserBId, title: 'isolation: medication' }),
72
+ },
73
+ {
74
+ model: 'enduser_orders',
75
+ ownerField: 'enduserId',
76
+ buildPayload: (enduserBId) => ({
77
+ enduserId: enduserBId,
78
+ title: 'isolation: order',
79
+ status: 'pending',
80
+ source: 'isolation-test',
81
+ externalId: `iso-${Date.now()}`,
82
+ }),
83
+ },
84
+ {
85
+ model: 'enduser_problems',
86
+ ownerField: 'enduserId',
87
+ buildPayload: (enduserBId) => ({
88
+ enduserId: enduserBId,
89
+ title: 'isolation: problem',
90
+ }),
91
+ },
92
+ {
93
+ model: 'managed_content_record_assignments',
94
+ ownerField: 'enduserId',
95
+ setup: async (sdk, _enduserBId) => {
96
+ const content = await sdk.api.managed_content_records.createOne({
97
+ title: `isolation content ${Date.now()}`,
98
+ textContent: 'isolation',
99
+ htmlContent: '<p>isolation</p>',
100
+ })
101
+ return {
102
+ payloadOverride: { contentId: content.id },
103
+ cleanup: async () => { await sdk.api.managed_content_records.deleteOne(content.id).catch(() => {}) },
104
+ }
105
+ },
106
+ buildPayload: (enduserBId) => ({ enduserId: enduserBId }),
107
+ },
108
+ {
109
+ model: 'purchases',
110
+ ownerField: 'enduserId',
111
+ buildPayload: (enduserBId) => ({
112
+ enduserId: enduserBId,
113
+ title: 'isolation: purchase',
114
+ cost: { amount: 100, currency: 'USD' },
115
+ processor: 'Stripe',
116
+ }),
117
+ },
118
+ {
119
+ model: 'purchase_credits',
120
+ ownerField: 'enduserId',
121
+ buildPayload: (enduserBId) => ({
122
+ enduserId: enduserBId,
123
+ title: 'isolation: credit',
124
+ value: { type: 'Credit', info: { amount: 50, currency: 'USD' } },
125
+ }),
126
+ },
127
+ {
128
+ model: 'chat_rooms',
129
+ ownerField: 'enduserIds',
130
+ buildPayload: (enduserBId) => ({
131
+ type: 'internal',
132
+ userIds: [],
133
+ enduserIds: [enduserBId],
134
+ title: 'isolation: chat_room',
135
+ }),
136
+ },
137
+ {
138
+ model: 'chats',
139
+ ownerField: 'enduserId',
140
+ setup: async (sdk, enduserBId) => {
141
+ const room = await sdk.api.chat_rooms.createOne({
142
+ type: 'internal',
143
+ userIds: [],
144
+ enduserIds: [enduserBId],
145
+ title: 'isolation: chats parent',
146
+ })
147
+ return {
148
+ payloadOverride: { roomId: room.id, senderId: enduserBId },
149
+ cleanup: async () => { await sdk.api.chat_rooms.deleteOne(room.id).catch(() => {}) },
150
+ }
151
+ },
152
+ buildPayload: (enduserBId) => ({ message: 'isolation chat', enduserId: enduserBId }),
153
+ },
154
+ {
155
+ model: 'calendar_events',
156
+ ownerField: 'attendees.id',
157
+ buildPayload: (enduserBId) => ({
158
+ title: 'isolation: calendar_event',
159
+ durationInMinutes: 30,
160
+ startTimeInMS: Date.now() + 86_400_000,
161
+ attendees: buildAttendees(enduserBId),
162
+ }),
163
+ },
164
+ {
165
+ model: 'ticket_threads',
166
+ ownerField: 'enduserId',
167
+ buildPayload: (enduserBId) => ({
168
+ enduserId: enduserBId,
169
+ subject: 'isolation: thread',
170
+ }),
171
+ },
172
+ {
173
+ model: 'ticket_thread_comments',
174
+ ownerField: 'enduserId',
175
+ setup: async (sdk, enduserBId) => {
176
+ const thread = await sdk.api.ticket_threads.createOne({
177
+ enduserId: enduserBId,
178
+ subject: 'isolation: thread comments parent',
179
+ })
180
+ return {
181
+ payloadOverride: { ticketThreadId: thread.id },
182
+ cleanup: async () => { await sdk.api.ticket_threads.deleteOne(thread.id).catch(() => {}) },
183
+ }
184
+ },
185
+ buildPayload: (enduserBId) => ({
186
+ enduserId: enduserBId,
187
+ ticketThreadId: '',
188
+ html: '<p>isolation</p>',
189
+ plaintext: 'isolation',
190
+ inbound: true,
191
+ public: false,
192
+ }),
193
+ },
194
+ {
195
+ model: 'form_responses',
196
+ ownerField: 'enduserId',
197
+ setup: async (sdk, _enduserBId) => {
198
+ const form = await sdk.api.forms.createOne({ title: `isolation form ${Date.now()}` })
199
+ return {
200
+ payloadOverride: { formId: form.id },
201
+ cleanup: async () => { await sdk.api.forms.deleteOne(form.id).catch(() => {}) },
202
+ }
203
+ },
204
+ buildPayload: (enduserBId) => ({
205
+ enduserId: enduserBId,
206
+ formId: '',
207
+ formTitle: 'isolation form',
208
+ }),
209
+ },
210
+ {
211
+ model: 'enduser_eligibility_results',
212
+ ownerField: 'enduserId',
213
+ buildPayload: (enduserBId) => ({
214
+ enduserId: enduserBId,
215
+ title: 'isolation: eligibility',
216
+ type: 'Prescription',
217
+ externalId: `iso-${Date.now()}`,
218
+ source: 'isolation-test',
219
+ status: 'Pending',
220
+ }),
221
+ },
222
+ ]
223
+
224
+ const COVERED_MODELS = new Set(MODEL_CASES.map(c => c.model))
225
+
226
+ // Models that have a FilterAccessConstraint on enduserId / enduserIds /
227
+ // attendees.id but are intentionally NOT exercised here. Each entry must
228
+ // include a one-line reason. Empty by default; add only with justification.
229
+ const EXEMPT_MODELS: { model: string, reason: string }[] = [
230
+ {
231
+ model: 'meetings',
232
+ reason: 'No default CRUD ops — created via admin-only start_meeting and read by endusers via custom my_meetings/read/join_meeting_for_event actions, which do not match this fixture pattern.',
233
+ },
234
+ ]
235
+
236
+ const RELEVANT_OWNER_FIELDS = new Set<string>(['enduserId', 'enduserIds', 'attendees.id'])
237
+
238
+ const discoverEnduserScopedModels = (): string[] => {
239
+ const found: string[] = []
240
+ for (const name of Object.keys(schema) as (keyof typeof schema)[]) {
241
+ const model = schema[name] as any
242
+ const access = model?.constraints?.access as Array<{ type: string, field?: string }> | undefined
243
+ if (!access) continue
244
+ const hasFilter = access.some(c => c?.type === 'filter' && typeof c.field === 'string' && RELEVANT_OWNER_FIELDS.has(c.field))
245
+ if (hasFilter) found.push(name as string)
246
+ }
247
+ return found
248
+ }
249
+
250
+ const assertNotPresent = (records: { id: string }[], id: string, label: string) => {
251
+ assert(
252
+ !records.find(r => r.id === id),
253
+ `${label} returned record owned by other enduser (id=${id})`,
254
+ label,
255
+ )
256
+ }
257
+
258
+ const expectForbidden = <T>(label: string, run: () => Promise<T>) => async_test(label, run, handleAnyError)
259
+
260
+ const expectEmptyOrForbidden = <T>(label: string, run: () => Promise<T[]>) => async_test(
261
+ label,
262
+ async () => {
263
+ try {
264
+ const r = await run()
265
+ return { rejected: false, length: r.length }
266
+ } catch (_e) {
267
+ return { rejected: true, length: 0 }
268
+ }
269
+ },
270
+ { onResult: r => r.rejected || r.length === 0 },
271
+ )
272
+
273
+ const expectMatchesEmptyOrForbidden = (label: string, run: () => Promise<{ matches: any[] }>) => async_test(
274
+ label,
275
+ async () => {
276
+ try {
277
+ const r = await run()
278
+ return { rejected: false, length: (r?.matches ?? []).length }
279
+ } catch (_e) {
280
+ return { rejected: true, length: 0 }
281
+ }
282
+ },
283
+ { onResult: r => r.rejected || r.length === 0 },
284
+ )
285
+
286
+ const expectGetOneFails = <T>(label: string, run: () => Promise<T>) => async_test(
287
+ label,
288
+ async () => {
289
+ try {
290
+ const r = await run()
291
+ // some models may return undefined on no-match; that's also acceptable
292
+ return { rejected: false, found: !!r }
293
+ } catch (_e) {
294
+ return { rejected: true, found: false }
295
+ }
296
+ },
297
+ { onResult: r => r.rejected || !r.found },
298
+ )
299
+
300
+ const recordHasOwner = (record: any, ownerField: OwnerField, enduserId: string): boolean => {
301
+ if (!record) return false
302
+ if (ownerField === 'enduserId') return record.enduserId === enduserId
303
+ if (ownerField === 'enduserIds') return Array.isArray(record.enduserIds) && record.enduserIds.includes(enduserId)
304
+ if (ownerField === 'attendees.id') return Array.isArray(record.attendees) && record.attendees.some((a: any) => a?.id === enduserId)
305
+ return false
306
+ }
307
+
308
+ export const enduser_cross_access_isolation_tests = async (
309
+ { sdk, sdkNonAdmin: _sdkNonAdmin }: { sdk: Session, sdkNonAdmin: Session }
310
+ ) => {
311
+ log_header("Enduser cross-access isolation")
312
+
313
+ // ===== Schema drift guard =====
314
+ const discovered = discoverEnduserScopedModels()
315
+ const exemptSet = new Set(EXEMPT_MODELS.map(e => e.model))
316
+ const missing = discovered.filter(m => !COVERED_MODELS.has(m) && !exemptSet.has(m))
317
+ assert(
318
+ missing.length === 0,
319
+ `Missing isolation coverage for enduser-scoped models: ${missing.join(', ')}. ` +
320
+ `Add to MODEL_CASES or EXEMPT_MODELS in enduser_cross_access_isolation.test.ts.`,
321
+ 'schema drift guard: every FilterAccessConstraint on enduserId/enduserIds/attendees.id is covered',
322
+ )
323
+
324
+ const password = 'IsolationTestPassword123!'
325
+ const ts = Date.now()
326
+ const enduserA = await sdk.api.endusers.createOne({ email: `iso_a_${ts}@test.tellescope.com` })
327
+ const enduserB = await sdk.api.endusers.createOne({ email: `iso_b_${ts}@test.tellescope.com` })
328
+
329
+ await sdk.api.endusers.set_password({ id: enduserA.id, password })
330
+ await sdk.api.endusers.set_password({ id: enduserB.id, password })
331
+
332
+ const sdkA = new EnduserSession({ host, businessId })
333
+ const sdkB = new EnduserSession({ host, businessId })
334
+
335
+ try {
336
+ // Sanity check: each enduser session can authenticate. We only use sdkA
337
+ // for negative assertions, but a failed sdkB auth would mean the test
338
+ // data setup itself is malformed.
339
+ await sdkA.authenticate(enduserA.email!, password)
340
+ await sdkB.authenticate(enduserB.email!, password)
341
+
342
+ for (const c of MODEL_CASES) {
343
+ const sublog = (variant: string) => `${c.model}: ${variant}`
344
+
345
+ let setupResult: ModelSetupResult | undefined
346
+ if (c.setup) {
347
+ try {
348
+ setupResult = await c.setup(sdk, enduserB.id)
349
+ } catch (e) {
350
+ assert(false, `${c.model}: setup hook failed: ${(e as Error).message}`, sublog('setup'))
351
+ continue
352
+ }
353
+ }
354
+
355
+ const payload = { ...c.buildPayload(enduserB.id), ...(setupResult?.payloadOverride ?? {}) }
356
+
357
+ let createdId: string | undefined
358
+ try {
359
+ const created = await (sdk.api as any)[c.model].createOne(payload)
360
+ createdId = created?.id
361
+ } catch (e) {
362
+ assert(false, `${c.model}: admin createOne failed: ${(e as Error).message}`, sublog('admin createOne'))
363
+ if (setupResult) await setupResult.cleanup().catch(() => {})
364
+ continue
365
+ }
366
+
367
+ if (!createdId) {
368
+ assert(false, `${c.model}: admin createOne did not return an id`, sublog('admin createOne'))
369
+ if (setupResult) await setupResult.cleanup().catch(() => {})
370
+ continue
371
+ }
372
+
373
+ const enduserApi = (sdkA.api as any)[c.model]
374
+
375
+ try {
376
+ // 0a. Fixture sanity: admin can re-fetch the record AND its owner
377
+ // field is actually set to enduser B. Without this, A's negative
378
+ // assertions could pass trivially (e.g. if createOne silently
379
+ // dropped the ownership field, no enduser would ever match).
380
+ await async_test(
381
+ sublog('fixture: admin re-fetch confirms ownership set to enduser B'),
382
+ async () => {
383
+ const fetched = await (sdk.api as any)[c.model].getOne(createdId!)
384
+ return { fetched, owned: recordHasOwner(fetched, c.ownerField, enduserB.id) }
385
+ },
386
+ { onResult: r => !!r.fetched && r.owned === true },
387
+ )
388
+
389
+ // 0b. Fixture sanity: enduser B (the legitimate owner) can see the
390
+ // record via getOne. Models that block all enduser reads (empty
391
+ // enduserActions) will reject — that's accepted. The check fails
392
+ // only if B is "found" returns a different record id.
393
+ await async_test(
394
+ sublog('fixture: owner enduser B can fetch own record (or model blocks endusers)'),
395
+ async () => {
396
+ try {
397
+ const fetched = await (sdkB.api as any)[c.model].getOne(createdId!)
398
+ return { rejected: false, matched: !!fetched && fetched.id === createdId }
399
+ } catch (_e) {
400
+ return { rejected: true, matched: false }
401
+ }
402
+ },
403
+ { onResult: r => r.rejected || r.matched },
404
+ )
405
+
406
+ // 1. getOne by id — expect throw or undefined
407
+ await expectGetOneFails(
408
+ sublog('getOne by id rejects or returns nothing'),
409
+ () => enduserApi.getOne(createdId!),
410
+ )
411
+
412
+ // 2. getOne by ownership filter — expect throw or undefined
413
+ await expectGetOneFails(
414
+ sublog('getOne by owner filter rejects or returns nothing'),
415
+ () => enduserApi.getOne({ [c.ownerField]: enduserB.id }),
416
+ )
417
+
418
+ // 3. getSome (no filter) — record must not appear (or call rejected)
419
+ await async_test(
420
+ sublog('getSome (no filter) excludes other-enduser record'),
421
+ async () => {
422
+ try {
423
+ const records: { id: string }[] = await enduserApi.getSome({})
424
+ return { rejected: false, hasRecord: !!records.find((r: any) => r.id === createdId) }
425
+ } catch (_e) {
426
+ return { rejected: true, hasRecord: false }
427
+ }
428
+ },
429
+ { onResult: r => r.rejected || !r.hasRecord },
430
+ )
431
+
432
+ // 4. getSome (owner filter) — must be empty (or rejected)
433
+ await expectEmptyOrForbidden(
434
+ sublog('getSome (owner filter) returns empty or rejects'),
435
+ () => enduserApi.getSome({ filter: { [c.ownerField]: enduserB.id } }),
436
+ )
437
+
438
+ // 5. getByIds — matches must be empty (or rejected)
439
+ await expectMatchesEmptyOrForbidden(
440
+ sublog('getByIds returns no matches or rejects'),
441
+ () => enduserApi.getByIds({ ids: [createdId!] }),
442
+ )
443
+
444
+ // 6. /bulk-actions/read with owner filter
445
+ await async_test(
446
+ sublog('bulk_load (owner filter) returns null or empty for other-enduser data'),
447
+ async () => {
448
+ try {
449
+ const r = await sdkA.bulk_load({
450
+ load: [{
451
+ model: c.model as ModelName,
452
+ options: { filter: { [c.ownerField]: enduserB.id } },
453
+ }],
454
+ })
455
+ const result = r.results[0]
456
+ if (result === null) return { ok: true }
457
+ return { ok: result.records.length === 0, count: result.records.length }
458
+ } catch (_e) {
459
+ return { ok: true } // rejection is also safe
460
+ }
461
+ },
462
+ { onResult: r => r.ok === true },
463
+ )
464
+
465
+ // 7. /bulk-actions/read with no filter — record must not appear
466
+ await async_test(
467
+ sublog('bulk_load (no filter) excludes other-enduser record'),
468
+ async () => {
469
+ try {
470
+ const r = await sdkA.bulk_load({
471
+ load: [{ model: c.model as ModelName, options: {} }],
472
+ })
473
+ const result = r.results[0]
474
+ if (result === null) return { ok: true }
475
+ return { ok: !result.records.find((rec: any) => rec.id === createdId) }
476
+ } catch (_e) {
477
+ return { ok: true }
478
+ }
479
+ },
480
+ { onResult: r => r.ok === true },
481
+ )
482
+
483
+ // 8. updateOne — expect throw
484
+ await expectForbidden(
485
+ sublog('updateOne rejects'),
486
+ () => enduserApi.updateOne(createdId!, { /* no-op-ish update */ } as any),
487
+ )
488
+
489
+ // 9. deleteOne — expect throw
490
+ await expectForbidden(
491
+ sublog('deleteOne rejects'),
492
+ () => enduserApi.deleteOne(createdId!),
493
+ )
494
+
495
+ // After all attempted writes, confirm the record still exists when
496
+ // fetched as admin — i.e. enduser A's failed update/delete were no-ops.
497
+ await async_test(
498
+ sublog('record still exists after failed enduser writes (admin verify)'),
499
+ async () => {
500
+ try {
501
+ const found = await (sdk.api as any)[c.model].getOne(createdId!)
502
+ return !!found && found.id === createdId
503
+ } catch {
504
+ return false
505
+ }
506
+ },
507
+ { onResult: r => r === true },
508
+ )
509
+ } finally {
510
+ // Admin-side cleanup of the record itself
511
+ try {
512
+ await (sdk.api as any)[c.model].deleteOne(createdId).catch(() => {})
513
+ } catch { /* ignore */ }
514
+ if (setupResult) await setupResult.cleanup().catch(() => {})
515
+ }
516
+ }
517
+ } finally {
518
+ await sdk.api.endusers.deleteOne(enduserA.id).catch(() => {})
519
+ await sdk.api.endusers.deleteOne(enduserB.id).catch(() => {})
520
+ }
521
+ }
522
+
523
+ if (require.main === module) {
524
+ console.log(`Using API URL: ${host}`)
525
+ const sdk = new Session({ host })
526
+ const sdkNonAdmin = new Session({ host })
527
+
528
+ const runTests = async () => {
529
+ await setup_tests(sdk, sdkNonAdmin)
530
+ await enduser_cross_access_isolation_tests({ sdk, sdkNonAdmin })
531
+ }
532
+
533
+ runTests()
534
+ .then(() => {
535
+ console.log("Enduser cross-access isolation test suite completed successfully")
536
+ process.exit(0)
537
+ })
538
+ .catch((error) => {
539
+ console.error("Enduser cross-access isolation test suite failed:", error)
540
+ process.exit(1)
541
+ })
542
+ }
@@ -50,7 +50,7 @@ import { appointment_rescheduled_trigger_tests } from "./api_tests/appointment_r
50
50
  import { journey_error_branching_tests } from "./api_tests/journey_error_branching.test"
51
51
  import { afteraction_day_of_month_delay_tests } from "./api_tests/afteraction_day_of_month_delay.test"
52
52
  import { setup_tests } from "./setup"
53
- import { evaluate_conditional_logic_for_enduser_fields, FORM_LOGIC_CALCULATED_FIELDS, get_care_team_primary, get_flattened_fields, get_next_reminder_timestamp, object_is_empty, replace_enduser_template_values, responses_satisfy_conditions, truncate_string, weighted_round_robin, YYYY_MM_DD_to_MM_DD_YYYY } from "@tellescope/utilities"
53
+ import { evaluate_conditional_logic_for_enduser_fields, FORM_LOGIC_CALCULATED_FIELDS, get_care_team_primary, get_flattened_fields, get_next_reminder_timestamp, object_is_empty, replace_enduser_template_values, replace_form_field_template_values, responses_satisfy_conditions, truncate_string, weighted_round_robin, YYYY_MM_DD_to_MM_DD_YYYY } from "@tellescope/utilities"
54
54
  import { DEFAULT_OPERATIONS, PLACEHOLDER_ID, ZENDESK_INTEGRATIONS_TITLE, ZOOM_TITLE } from "@tellescope/constants"
55
55
  import {
56
56
  schema,
@@ -102,6 +102,7 @@ import { openloop_webhooks_tests } from "./api_tests/openloop_webhooks.test";
102
102
  import { beluga_pharmacy_mappings_tests } from "./api_tests/beluga_pharmacy_mappings.test";
103
103
  import { date_string_validation_tests } from "./api_tests/date_string_validation.test";
104
104
  import { enduser_session_invalidation_tests } from "./api_tests/enduser_session_invalidation.test";
105
+ import { enduser_cross_access_isolation_tests } from "./api_tests/enduser_cross_access_isolation.test";
105
106
 
106
107
  const UniquenessViolationMessage = 'Uniqueness Violation'
107
108
 
@@ -12907,6 +12908,87 @@ const replace_enduser_template_values_tests = async () => {
12907
12908
  await sdk.api.endusers.deleteOne(enduser.id)
12908
12909
  }
12909
12910
 
12911
+ const replace_form_field_template_values_tests = async () => {
12912
+ log_header("Replace Form Field Template Values Tests")
12913
+
12914
+ const enduserWithMultilineField = {
12915
+ fname: "Multi",
12916
+ lname: "Line",
12917
+ fields: { Locations: 'NYC\nSF\nLA' },
12918
+ } as Partial<Enduser>
12919
+
12920
+ // With escapeNewlinesAsHTMLBreaks: true — newlines in substituted value become <br />
12921
+ assert(
12922
+ replace_form_field_template_values(
12923
+ '<p>Locations: {{enduser.Locations}}</p>',
12924
+ { enduser: enduserWithMultilineField, escapeNewlinesAsHTMLBreaks: true }
12925
+ ) === '<p>Locations: NYC<br />SF<br />LA</p>',
12926
+ 'fail escapeNewlinesAsHTMLBreaks true', 'escapeNewlinesAsHTMLBreaks true'
12927
+ )
12928
+
12929
+ // Default (option absent) — newlines preserved as \n
12930
+ assert(
12931
+ replace_form_field_template_values(
12932
+ '<p>Locations: {{enduser.Locations}}</p>',
12933
+ { enduser: enduserWithMultilineField }
12934
+ ) === '<p>Locations: NYC\nSF\nLA</p>',
12935
+ 'fail default newline preserved', 'default newline preserved'
12936
+ )
12937
+
12938
+ // Explicit false — same as default
12939
+ assert(
12940
+ replace_form_field_template_values(
12941
+ '<p>Locations: {{enduser.Locations}}</p>',
12942
+ { enduser: enduserWithMultilineField, escapeNewlinesAsHTMLBreaks: false }
12943
+ ) === '<p>Locations: NYC\nSF\nLA</p>',
12944
+ 'fail escapeNewlinesAsHTMLBreaks false', 'escapeNewlinesAsHTMLBreaks false'
12945
+ )
12946
+
12947
+ // \n in original template (not in substituted value) is left alone
12948
+ assert(
12949
+ replace_form_field_template_values(
12950
+ '<p>Header</p>\n<p>{{enduser.fname}}</p>',
12951
+ { enduser: enduserWithMultilineField, escapeNewlinesAsHTMLBreaks: true }
12952
+ ) === '<p>Header</p>\n<p>Multi</p>',
12953
+ 'fail template newline untouched', 'template newline untouched'
12954
+ )
12955
+
12956
+ // Single-line value unaffected when option is enabled
12957
+ assert(
12958
+ replace_form_field_template_values(
12959
+ '<p>Hello {{enduser.fname}}</p>',
12960
+ { enduser: enduserWithMultilineField, escapeNewlinesAsHTMLBreaks: true }
12961
+ ) === '<p>Hello Multi</p>',
12962
+ 'fail single-line value', 'single-line value'
12963
+ )
12964
+
12965
+ // Substituted value containing literal two-char \n escape sequence is also converted
12966
+ const enduserWithLiteralEscapeField = {
12967
+ fname: "Multi",
12968
+ fields: { Locations: 'NYC\\nSF\\nLA' },
12969
+ } as Partial<Enduser>
12970
+ assert(
12971
+ replace_form_field_template_values(
12972
+ '<p>Locations: {{enduser.Locations}}</p>',
12973
+ { enduser: enduserWithLiteralEscapeField, escapeNewlinesAsHTMLBreaks: true }
12974
+ ) === '<p>Locations: NYC<br />SF<br />LA</p>',
12975
+ 'fail literal \\n escape in substituted value', 'literal \\n escape in substituted value'
12976
+ )
12977
+
12978
+ // Substituted value with \r\n is also converted (single break per CRLF, not two)
12979
+ const enduserWithCRLFField = {
12980
+ fname: "Multi",
12981
+ fields: { Locations: 'NYC\r\nSF\r\nLA' },
12982
+ } as Partial<Enduser>
12983
+ assert(
12984
+ replace_form_field_template_values(
12985
+ '<p>Locations: {{enduser.Locations}}</p>',
12986
+ { enduser: enduserWithCRLFField, escapeNewlinesAsHTMLBreaks: true }
12987
+ ) === '<p>Locations: NYC<br />SF<br />LA</p>',
12988
+ 'fail CRLF in substituted value', 'CRLF in substituted value'
12989
+ )
12990
+ }
12991
+
12910
12992
  const inbox_threads_building_tests = async () => {
12911
12993
  log_header("Inbox Thread Building Tests")
12912
12994
 
@@ -13257,15 +13339,13 @@ const inbox_threads_building_tests = async () => {
13257
13339
  threads.length === 16 // only the new call should result in a new thread
13258
13340
  &&
13259
13341
  threads
13260
- .filter(t =>
13261
- t.threadId === outboundCall.id
13262
- || (
13342
+ .filter(t =>
13263
13343
  !!t.outboundTimestamp
13264
13344
  && !!t.outboundPreview
13265
- && new Date(t.outboundTimestamp).getTime() > new Date(t.timestamp).getTime()
13266
- )
13345
+ // SMS uses ObjectId second-precision for both fields; allow equal when inbound/outbound land in the same second
13346
+ && new Date(t.outboundTimestamp).getTime() >= new Date(t.timestamp).getTime()
13267
13347
  )
13268
- .length === 4 // all channels except call
13348
+ .length === 4 // all channels except call
13269
13349
  )}
13270
13350
  )
13271
13351
 
@@ -14203,8 +14283,10 @@ const ip_address_form_tests = async () => {
14203
14283
 
14204
14284
  await enduser_conditional_logic_tests()
14205
14285
  await replace_enduser_template_values_tests()
14286
+ await replace_form_field_template_values_tests()
14206
14287
  await mfa_tests()
14207
14288
  await setup_tests(sdk, sdkNonAdmin)
14289
+ await enduser_cross_access_isolation_tests({ sdk, sdkNonAdmin })
14208
14290
  await eom_procedure_codes_tests({ sdk, sdkNonAdmin })
14209
14291
  await cross_org_api_key_tests({ sdk, sdkNonAdmin })
14210
14292
  await organization_settings_duplicates_tests({ sdk, sdkNonAdmin })
Binary file