@tellescope/sdk 1.249.1 → 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.
@@ -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
+ }
@@ -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
 
@@ -13338,15 +13339,13 @@ const inbox_threads_building_tests = async () => {
13338
13339
  threads.length === 16 // only the new call should result in a new thread
13339
13340
  &&
13340
13341
  threads
13341
- .filter(t =>
13342
- t.threadId === outboundCall.id
13343
- || (
13342
+ .filter(t =>
13344
13343
  !!t.outboundTimestamp
13345
13344
  && !!t.outboundPreview
13346
- && new Date(t.outboundTimestamp).getTime() > new Date(t.timestamp).getTime()
13347
- )
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()
13348
13347
  )
13349
- .length === 4 // all channels except call
13348
+ .length === 4 // all channels except call
13350
13349
  )}
13351
13350
  )
13352
13351
 
@@ -14287,6 +14286,7 @@ const ip_address_form_tests = async () => {
14287
14286
  await replace_form_field_template_values_tests()
14288
14287
  await mfa_tests()
14289
14288
  await setup_tests(sdk, sdkNonAdmin)
14289
+ await enduser_cross_access_isolation_tests({ sdk, sdkNonAdmin })
14290
14290
  await eom_procedure_codes_tests({ sdk, sdkNonAdmin })
14291
14291
  await cross_org_api_key_tests({ sdk, sdkNonAdmin })
14292
14292
  await organization_settings_duplicates_tests({ sdk, sdkNonAdmin })
Binary file