@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.
- package/lib/cjs/enduser.d.ts +12 -2
- package/lib/cjs/enduser.d.ts.map +1 -1
- package/lib/cjs/enduser.js +11 -0
- package/lib/cjs/enduser.js.map +1 -1
- package/lib/cjs/tests/api_tests/enduser_cross_access_isolation.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/enduser_cross_access_isolation.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/enduser_cross_access_isolation.test.js +782 -0
- package/lib/cjs/tests/api_tests/enduser_cross_access_isolation.test.js.map +1 -0
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +145 -141
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/esm/enduser.d.ts +12 -2
- package/lib/esm/enduser.d.ts.map +1 -1
- package/lib/esm/enduser.js +11 -0
- package/lib/esm/enduser.js.map +1 -1
- package/lib/esm/sdk.d.ts +2 -2
- package/lib/esm/tests/api_tests/enduser_cross_access_isolation.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/enduser_cross_access_isolation.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/enduser_cross_access_isolation.test.js +778 -0
- package/lib/esm/tests/api_tests/enduser_cross_access_isolation.test.js.map +1 -0
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +145 -141
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/enduser.ts +8 -2
- package/src/tests/api_tests/enduser_cross_access_isolation.test.ts +542 -0
- package/src/tests/tests.ts +6 -6
- 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
|
+
}
|
package/src/tests/tests.ts
CHANGED
|
@@ -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
|
-
|
|
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 })
|
package/test_generated.pdf
CHANGED
|
Binary file
|