@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.
- 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 +182 -143
- 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/session.d.ts +0 -1
- package/lib/esm/session.d.ts.map +1 -1
- 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 +183 -144
- 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 +89 -7
- 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
|
@@ -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
|
-
|
|
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 })
|
package/test_generated.pdf
CHANGED
|
Binary file
|