@tellescope/sdk 1.249.1 → 1.250.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/lib/cjs/enduser.d.ts +14 -2
  2. package/lib/cjs/enduser.d.ts.map +1 -1
  3. package/lib/cjs/enduser.js +14 -3
  4. package/lib/cjs/enduser.js.map +1 -1
  5. package/lib/cjs/sdk.d.ts +1 -0
  6. package/lib/cjs/sdk.d.ts.map +1 -1
  7. package/lib/cjs/sdk.js +3 -3
  8. package/lib/cjs/sdk.js.map +1 -1
  9. package/lib/cjs/tests/api_tests/beluga_pharmacy_mappings.test.d.ts.map +1 -1
  10. package/lib/cjs/tests/api_tests/beluga_pharmacy_mappings.test.js +46 -11
  11. package/lib/cjs/tests/api_tests/beluga_pharmacy_mappings.test.js.map +1 -1
  12. package/lib/cjs/tests/api_tests/enduser_cross_access_isolation.test.d.ts +6 -0
  13. package/lib/cjs/tests/api_tests/enduser_cross_access_isolation.test.d.ts.map +1 -0
  14. package/lib/cjs/tests/api_tests/enduser_cross_access_isolation.test.js +782 -0
  15. package/lib/cjs/tests/api_tests/enduser_cross_access_isolation.test.js.map +1 -0
  16. package/lib/cjs/tests/tests.d.ts.map +1 -1
  17. package/lib/cjs/tests/tests.js +173 -156
  18. package/lib/cjs/tests/tests.js.map +1 -1
  19. package/lib/esm/enduser.d.ts +14 -2
  20. package/lib/esm/enduser.d.ts.map +1 -1
  21. package/lib/esm/enduser.js +14 -3
  22. package/lib/esm/enduser.js.map +1 -1
  23. package/lib/esm/sdk.d.ts +3 -2
  24. package/lib/esm/sdk.d.ts.map +1 -1
  25. package/lib/esm/sdk.js +3 -3
  26. package/lib/esm/sdk.js.map +1 -1
  27. package/lib/esm/tests/api_tests/beluga_pharmacy_mappings.test.d.ts.map +1 -1
  28. package/lib/esm/tests/api_tests/beluga_pharmacy_mappings.test.js +46 -11
  29. package/lib/esm/tests/api_tests/beluga_pharmacy_mappings.test.js.map +1 -1
  30. package/lib/esm/tests/api_tests/enduser_cross_access_isolation.test.d.ts +6 -0
  31. package/lib/esm/tests/api_tests/enduser_cross_access_isolation.test.d.ts.map +1 -0
  32. package/lib/esm/tests/api_tests/enduser_cross_access_isolation.test.js +778 -0
  33. package/lib/esm/tests/api_tests/enduser_cross_access_isolation.test.js.map +1 -0
  34. package/lib/esm/tests/tests.d.ts.map +1 -1
  35. package/lib/esm/tests/tests.js +173 -156
  36. package/lib/esm/tests/tests.js.map +1 -1
  37. package/lib/tsconfig.tsbuildinfo +1 -1
  38. package/package.json +10 -10
  39. package/src/enduser.ts +12 -6
  40. package/src/sdk.ts +3 -3
  41. package/src/tests/api_tests/beluga_pharmacy_mappings.test.ts +55 -0
  42. package/src/tests/api_tests/enduser_cross_access_isolation.test.ts +542 -0
  43. package/src/tests/tests.ts +29 -8
  44. package/test_generated.pdf +0 -0
@@ -261,6 +261,61 @@ export const beluga_pharmacy_mappings_tests = async ({ sdk, sdkNonAdmin }: { sdk
261
261
  { expectedResult: false }
262
262
  )
263
263
 
264
+ await async_test(
265
+ "$ne: value equals (negative case)",
266
+ async () => {
267
+ return evaluate_mapping_conditions(
268
+ { condition: { "state": { $ne: "CA" } as any } },
269
+ [{ externalId: "state", value: "CA" }]
270
+ )
271
+ },
272
+ { expectedResult: false }
273
+ )
274
+
275
+ await async_test(
276
+ "$doesNotContain: value lacks substring",
277
+ async () => {
278
+ return evaluate_mapping_conditions(
279
+ { condition: { "meds": { $doesNotContain: "GLP" } as any } },
280
+ [{ externalId: "meds", value: "Semaglutide" }]
281
+ )
282
+ },
283
+ { expectedResult: true }
284
+ )
285
+
286
+ await async_test(
287
+ "$doesNotContain: value contains substring (negative case)",
288
+ async () => {
289
+ return evaluate_mapping_conditions(
290
+ { condition: { "meds": { $doesNotContain: "GLP" } as any } },
291
+ [{ externalId: "meds", value: "GLP-1 agonist" }]
292
+ )
293
+ },
294
+ { expectedResult: false }
295
+ )
296
+
297
+ await async_test(
298
+ "$exists false (isNotSet): field absent",
299
+ async () => {
300
+ return evaluate_mapping_conditions(
301
+ { condition: { "pharmacyOverride": { $exists: false } as any } },
302
+ [{ externalId: "other_field", value: "some-value" }]
303
+ )
304
+ },
305
+ { expectedResult: true }
306
+ )
307
+
308
+ await async_test(
309
+ "$exists false (isNotSet): field present (negative case)",
310
+ async () => {
311
+ return evaluate_mapping_conditions(
312
+ { condition: { "pharmacyOverride": { $exists: false } as any } },
313
+ [{ externalId: "pharmacyOverride", value: "some-value" }]
314
+ )
315
+ },
316
+ { expectedResult: false }
317
+ )
318
+
264
319
  await async_test(
265
320
  "Nested compound: $and with $or",
266
321
  async () => {
@@ -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
 
@@ -1685,10 +1686,31 @@ const files_tests = async () => {
1685
1686
  { onResult: t => true }
1686
1687
  )
1687
1688
 
1688
- const { presignedUpload, file } = await sdk.api.files.prepare_file_upload({
1689
- name: 'Test Private', size: buff.byteLength, type: 'text/plain', enduserId: enduser.id,
1689
+ const { presignedUpload, file } = await sdk.api.files.prepare_file_upload({
1690
+ name: 'Test Private', size: buff.byteLength, type: 'text/plain', enduserId: enduser.id,
1691
+ tags: ['unit-test', 'prepare-upload'],
1690
1692
  })
1691
1693
 
1694
+ assert(
1695
+ !!file.tags && file.tags.includes('unit-test') && file.tags.includes('prepare-upload'),
1696
+ 'tags not persisted on prepare_file_upload response',
1697
+ 'prepare_file_upload returns tags',
1698
+ )
1699
+
1700
+ await async_test(
1701
+ `tags persisted on file via prepare_file_upload`,
1702
+ () => sdk.api.files.getSome({ filter: { enduserId: enduser.id } }),
1703
+ {
1704
+ onResult: fs => {
1705
+ const stored = fs.find(f => f.id === file.id)
1706
+ return !!stored
1707
+ && !!stored.tags
1708
+ && stored.tags.includes('unit-test')
1709
+ && stored.tags.includes('prepare-upload')
1710
+ },
1711
+ },
1712
+ )
1713
+
1692
1714
  const { presignedUpload: presignedNonEnduser, file: fileNonEnduser } = await sdk.api.files.prepare_file_upload({
1693
1715
  name: 'Test Private (no enduser)', size: buff.byteLength, type: 'text/plain',
1694
1716
  })
@@ -13338,15 +13360,13 @@ const inbox_threads_building_tests = async () => {
13338
13360
  threads.length === 16 // only the new call should result in a new thread
13339
13361
  &&
13340
13362
  threads
13341
- .filter(t =>
13342
- t.threadId === outboundCall.id
13343
- || (
13363
+ .filter(t =>
13344
13364
  !!t.outboundTimestamp
13345
13365
  && !!t.outboundPreview
13346
- && new Date(t.outboundTimestamp).getTime() > new Date(t.timestamp).getTime()
13347
- )
13366
+ // SMS uses ObjectId second-precision for both fields; allow equal when inbound/outbound land in the same second
13367
+ && new Date(t.outboundTimestamp).getTime() >= new Date(t.timestamp).getTime()
13348
13368
  )
13349
- .length === 4 // all channels except call
13369
+ .length === 4 // all channels except call
13350
13370
  )}
13351
13371
  )
13352
13372
 
@@ -14287,6 +14307,7 @@ const ip_address_form_tests = async () => {
14287
14307
  await replace_form_field_template_values_tests()
14288
14308
  await mfa_tests()
14289
14309
  await setup_tests(sdk, sdkNonAdmin)
14310
+ await enduser_cross_access_isolation_tests({ sdk, sdkNonAdmin })
14290
14311
  await eom_procedure_codes_tests({ sdk, sdkNonAdmin })
14291
14312
  await cross_org_api_key_tests({ sdk, sdkNonAdmin })
14292
14313
  await organization_settings_duplicates_tests({ sdk, sdkNonAdmin })
Binary file