@tellescope/sdk 1.250.2 → 1.251.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 (64) hide show
  1. package/lib/cjs/sdk.d.ts +9 -0
  2. package/lib/cjs/sdk.d.ts.map +1 -1
  3. package/lib/cjs/sdk.js +3 -0
  4. package/lib/cjs/sdk.js.map +1 -1
  5. package/lib/cjs/tests/api_tests/account_switcher.test.d.ts.map +1 -1
  6. package/lib/cjs/tests/api_tests/account_switcher.test.js +1700 -306
  7. package/lib/cjs/tests/api_tests/account_switcher.test.js.map +1 -1
  8. package/lib/cjs/tests/api_tests/enduser_login.test.d.ts +6 -0
  9. package/lib/cjs/tests/api_tests/enduser_login.test.d.ts.map +1 -0
  10. package/lib/cjs/tests/api_tests/enduser_login.test.js +315 -0
  11. package/lib/cjs/tests/api_tests/enduser_login.test.js.map +1 -0
  12. package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts +6 -0
  13. package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts.map +1 -0
  14. package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.js +370 -0
  15. package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.js.map +1 -0
  16. package/lib/cjs/tests/api_tests/set_fields_order_templates.test.d.ts +6 -0
  17. package/lib/cjs/tests/api_tests/set_fields_order_templates.test.d.ts.map +1 -0
  18. package/lib/cjs/tests/api_tests/set_fields_order_templates.test.js +373 -0
  19. package/lib/cjs/tests/api_tests/set_fields_order_templates.test.js.map +1 -0
  20. package/lib/cjs/tests/setup.d.ts.map +1 -1
  21. package/lib/cjs/tests/setup.js +47 -32
  22. package/lib/cjs/tests/setup.js.map +1 -1
  23. package/lib/cjs/tests/tests.d.ts.map +1 -1
  24. package/lib/cjs/tests/tests.js +179 -158
  25. package/lib/cjs/tests/tests.js.map +1 -1
  26. package/lib/esm/sdk.d.ts +9 -0
  27. package/lib/esm/sdk.d.ts.map +1 -1
  28. package/lib/esm/sdk.js +3 -0
  29. package/lib/esm/sdk.js.map +1 -1
  30. package/lib/esm/tests/api_tests/account_switcher.test.d.ts.map +1 -1
  31. package/lib/esm/tests/api_tests/account_switcher.test.js +1702 -305
  32. package/lib/esm/tests/api_tests/account_switcher.test.js.map +1 -1
  33. package/lib/esm/tests/api_tests/enduser_login.test.d.ts +6 -0
  34. package/lib/esm/tests/api_tests/enduser_login.test.d.ts.map +1 -0
  35. package/lib/esm/tests/api_tests/enduser_login.test.js +308 -0
  36. package/lib/esm/tests/api_tests/enduser_login.test.js.map +1 -0
  37. package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.d.ts +6 -0
  38. package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.d.ts.map +1 -0
  39. package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.js +268 -0
  40. package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.js.map +1 -0
  41. package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts +6 -0
  42. package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts.map +1 -0
  43. package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.js +366 -0
  44. package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.js.map +1 -0
  45. package/lib/esm/tests/api_tests/set_fields_order_templates.test.d.ts +6 -0
  46. package/lib/esm/tests/api_tests/set_fields_order_templates.test.d.ts.map +1 -0
  47. package/lib/esm/tests/api_tests/set_fields_order_templates.test.js +369 -0
  48. package/lib/esm/tests/api_tests/set_fields_order_templates.test.js.map +1 -0
  49. package/lib/esm/tests/setup.d.ts.map +1 -1
  50. package/lib/esm/tests/setup.js +47 -32
  51. package/lib/esm/tests/setup.js.map +1 -1
  52. package/lib/esm/tests/tests.d.ts.map +1 -1
  53. package/lib/esm/tests/tests.js +179 -158
  54. package/lib/esm/tests/tests.js.map +1 -1
  55. package/lib/tsconfig.tsbuildinfo +1 -1
  56. package/package.json +10 -10
  57. package/src/sdk.ts +12 -0
  58. package/src/tests/api_tests/account_switcher.test.ts +1283 -0
  59. package/src/tests/api_tests/enduser_login.test.ts +215 -0
  60. package/src/tests/api_tests/push_forms_to_portal_group_completion.test.ts +198 -0
  61. package/src/tests/api_tests/set_fields_order_templates.test.ts +258 -0
  62. package/src/tests/setup.ts +8 -1
  63. package/src/tests/tests.ts +18 -5
  64. package/test_generated.pdf +0 -0
@@ -0,0 +1,1283 @@
1
+ require('source-map-support').install();
2
+
3
+ import { Session } from "../../sdk"
4
+ import {
5
+ async_test,
6
+ log_header,
7
+ assert,
8
+ wait,
9
+ } from "@tellescope/testing"
10
+ import { setup_tests } from "../setup"
11
+
12
+ const host = process.env.API_URL || 'http://localhost:8080' as const
13
+
14
+ const RAND = () => Math.random().toString(36).slice(2, 10)
15
+ const decode_jwt = (token: string): any => {
16
+ try {
17
+ const part = token.split('.')[1]
18
+ const json = Buffer.from(part.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')
19
+ return JSON.parse(json)
20
+ } catch { return null }
21
+ }
22
+
23
+ const passOnAnyResult = { shouldError: false, onResult: () => true }
24
+
25
+ export const account_switcher_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
26
+ log_header("Account Switcher Tests")
27
+
28
+ const adminId = sdk.userInfo.id
29
+ const nonAdminId = sdkNonAdmin.userInfo.id
30
+ const adminEmail = sdk.userInfo.email
31
+ const nonAdminEmail = sdkNonAdmin.userInfo.email
32
+ const nonAdminBusinessId = sdkNonAdmin.userInfo.businessId
33
+ const adminBusinessId = sdk.userInfo.businessId
34
+
35
+ const NON_ADMIN_EMAIL = process.env.NON_ADMIN_EMAIL
36
+ const NON_ADMIN_PASSWORD = process.env.NON_ADMIN_PASSWORD
37
+ if (!(NON_ADMIN_EMAIL && NON_ADMIN_PASSWORD)) {
38
+ throw new Error("NON_ADMIN_EMAIL and NON_ADMIN_PASSWORD must be set to run account_switcher_tests")
39
+ }
40
+
41
+ // helper: fetch current user record server-side for inspection
42
+ const get_user = async (s: Session, id: string) => s.api.users.getOne(id) as Promise<any>
43
+
44
+ // The standard CRUD update path rate-limits identical updates to the same record at
45
+ // 3/30s (routing.ts:2631). Cleanup PATCHes between sections all carry the same
46
+ // { linkedAccountAccess: [] } payload and would trip it. Workaround: include a rotating
47
+ // marker tag so every write has a unique JSON payload. Markers are stripped at end of run.
48
+ let __lac_resetCounter = 0
49
+ const sanitize_marker_tags = (tags: any[] = []) => tags.filter(t => typeof t === 'string' && !t.startsWith('__lac_'))
50
+ const set_linkedAccountAccess = async (s: Session, ownerId: string, entries: any[]) => {
51
+ __lac_resetCounter++
52
+ const me: any = await s.api.users.getOne(ownerId)
53
+ const newTags = [...sanitize_marker_tags(me?.tags ?? []), `__lac_${__lac_resetCounter}`]
54
+ await s.api.users.updateOne(ownerId, {
55
+ linkedAccountAccess: entries,
56
+ tags: newTags,
57
+ } as any, { replaceObjectFields: true })
58
+ }
59
+ // Idempotent clear — fetches current state and only writes if not already empty.
60
+ // Skips the PATCH (and its rate-limit consumption) when the array is already cleared.
61
+ const clear_linkedAccountAccess = async (s: Session, ownerId: string) => {
62
+ const me: any = await s.api.users.getOne(ownerId)
63
+ if (!((me?.linkedAccountAccess ?? []).length)) return
64
+ await set_linkedAccountAccess(s, ownerId, [])
65
+ }
66
+ const cleanup_marker_tags = async (s: Session, ownerId: string) => {
67
+ try {
68
+ const me: any = await s.api.users.getOne(ownerId)
69
+ const cleaned = sanitize_marker_tags(me?.tags ?? [])
70
+ if ((me?.tags ?? []).length !== cleaned.length) {
71
+ await s.api.users.updateOne(ownerId, { tags: cleaned } as any, { replaceObjectFields: true })
72
+ }
73
+ } catch { /* ignore */ }
74
+ }
75
+
76
+ await clear_linkedAccountAccess(sdk, adminId)
77
+ await clear_linkedAccountAccess(sdkNonAdmin, nonAdminId)
78
+
79
+ // The feature is opt-in per org. Enable it on the test business so the rest of the suite
80
+ // exercises the feature (the O section below explicitly toggles it off to verify gating).
81
+ const ensureOrgToggleEnabled = async () => {
82
+ const org: any = await sdk.api.organizations.getOne(adminBusinessId)
83
+ if (org?.accountSwitchingEnabled !== true) {
84
+ await sdk.api.organizations.updateOne(adminBusinessId, { accountSwitchingEnabled: true } as any)
85
+ await wait(undefined, 250)
86
+ }
87
+ }
88
+ await ensureOrgToggleEnabled()
89
+
90
+ // ============================================================
91
+ // A. Email immutability
92
+ // ============================================================
93
+ log_header("A. Email immutability")
94
+
95
+ // Tighter negative assertions — match the validation reason so a 500 won't pass silently.
96
+ const emailRejectMatcher = (e: any) => (
97
+ e.statusCode === 400
98
+ || /(updates|disabled|readonly|cannot)/i.test(e.message || '')
99
+ )
100
+ await async_test(
101
+ 'A1. Self PATCH of own email rejected',
102
+ () => sdkNonAdmin.api.users.updateOne(nonAdminId, { email: `evil-${RAND()}@tellescope.com` }),
103
+ { shouldError: true, onError: emailRejectMatcher }
104
+ )
105
+ await async_test(
106
+ 'A2. Admin PATCH of another user email rejected',
107
+ () => sdk.api.users.updateOne(nonAdminId, { email: `admin-rename-${RAND()}@tellescope.com` }),
108
+ { shouldError: true, onError: emailRejectMatcher }
109
+ )
110
+ await async_test(
111
+ 'A3. Admin PATCH of own email rejected',
112
+ () => sdk.api.users.updateOne(adminId, { email: `admin-self-${RAND()}@tellescope.com` }),
113
+ { shouldError: true, onError: emailRejectMatcher }
114
+ )
115
+ await async_test(
116
+ 'A4. verifiedEmail/email unchanged after rejected updates',
117
+ () => get_user(sdk, nonAdminId),
118
+ { shouldError: false, onResult: (u: any) => u.verifiedEmail === true && u.email === nonAdminEmail }
119
+ )
120
+
121
+ // A5: email IS settable on user creation
122
+ const seedEmail = `seed-${RAND()}@tellescope.com`
123
+ let createdSeedUserId = ''
124
+ await async_test(
125
+ 'A5. Admin can set email on user creation',
126
+ () => sdk.api.users.createOne({ email: seedEmail, fname: 'Seed', lname: 'User' } as any),
127
+ { shouldError: false, onResult: (u: any) => { createdSeedUserId = (u as any).id; return (u as any).email === seedEmail } }
128
+ )
129
+ if (createdSeedUserId) { try { await sdk.api.users.deleteOne(createdSeedUserId) } catch {} }
130
+
131
+ // ============================================================
132
+ // B. linkedAccountAccess PATCH-self validator
133
+ // ============================================================
134
+ log_header("B. linkedAccountAccess PATCH-self validator")
135
+
136
+ // Seed: nonAdmin requests access to admin
137
+ await sdkNonAdmin.api.users.request_linked_account_access({ targetEmail: adminEmail })
138
+ await wait(undefined, 250)
139
+
140
+ const adminAfterRequest = await get_user(sdk, adminId)
141
+ const pendingFromNonAdmin = (adminAfterRequest.linkedAccountAccess ?? []).find((e: any) => e.userId === nonAdminId)
142
+ assert(!!pendingFromNonAdmin && pendingFromNonAdmin.status === 'pending', 'no pending entry seeded for B tests', 'B-seed pending entry present')
143
+
144
+ // B14: PATCH self with array unchanged is a no-op
145
+ await async_test(
146
+ 'B14. PATCH self with linkedAccountAccess unchanged succeeds',
147
+ () => sdk.api.users.updateOne(adminId, { linkedAccountAccess: adminAfterRequest.linkedAccountAccess } as any, { replaceObjectFields: true }),
148
+ passOnAnyResult
149
+ )
150
+
151
+ // B5/B6: cannot add entries
152
+ const fakeEntry = {
153
+ userId: '000000000000000000000099',
154
+ email: 'fake@tellescope.com',
155
+ fname: 'Fake', lname: 'User', orgName: 'Fake Org',
156
+ status: 'accepted',
157
+ createdAt: new Date(),
158
+ requestExpiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
159
+ }
160
+ const validatorRejectMatcher = (e: any) => (
161
+ e.statusCode === 400
162
+ || e.statusCode === 404
163
+ // "No updates provided" fires when unknown fields (e.g. the removed accountAccessGrantedTo) get
164
+ // stripped by the schema and nothing valid remains — that's a legitimate rejection mechanism.
165
+ || /(linkedAccountAccess|owner|add entries|mutate|immutable|status can only|legacy|accountAccessGrantedTo|replaced|Could not find|No updates provided)/i.test(e.message || '')
166
+ )
167
+ await async_test(
168
+ 'B5. Cannot add accepted entry via PATCH',
169
+ () => sdk.api.users.updateOne(adminId, { linkedAccountAccess: [...(adminAfterRequest.linkedAccountAccess ?? []), fakeEntry] } as any, { replaceObjectFields: true }),
170
+ { shouldError: true, onError: validatorRejectMatcher }
171
+ )
172
+ await async_test(
173
+ 'B6. Cannot add pending entry via PATCH',
174
+ () => sdk.api.users.updateOne(adminId, { linkedAccountAccess: [...(adminAfterRequest.linkedAccountAccess ?? []), { ...fakeEntry, status: 'pending' }] } as any, { replaceObjectFields: true }),
175
+ { shouldError: true, onError: validatorRejectMatcher }
176
+ )
177
+
178
+ // B7-B13: cannot mutate immutable fields
179
+ const mutations: [string, any][] = [
180
+ ['userId', { ...pendingFromNonAdmin, userId: '000000000000000000000099' }],
181
+ ['email', { ...pendingFromNonAdmin, email: 'mutated@tellescope.com' }],
182
+ ['fname', { ...pendingFromNonAdmin, fname: 'MutatedF' }],
183
+ ['lname', { ...pendingFromNonAdmin, lname: 'MutatedL' }],
184
+ ['orgName', { ...pendingFromNonAdmin, orgName: 'MutatedOrg' }],
185
+ ['createdAt', { ...pendingFromNonAdmin, createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24) }],
186
+ ['requestExpiresAt', { ...pendingFromNonAdmin, requestExpiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365) }],
187
+ ]
188
+ for (const [label, mutated] of mutations) {
189
+ await async_test(
190
+ `B7-13. Cannot mutate linkedAccountAccess entry ${label}`,
191
+ () => sdk.api.users.updateOne(adminId, { linkedAccountAccess: [mutated] } as any, { replaceObjectFields: true }),
192
+ { shouldError: true, onError: validatorRejectMatcher }
193
+ )
194
+ }
195
+
196
+ // B3: pending -> accepted allowed
197
+ await async_test(
198
+ 'B3. Owner can flip pending -> accepted',
199
+ () => set_linkedAccountAccess(sdk, adminId, [{ ...pendingFromNonAdmin, status: 'accepted' }]),
200
+ passOnAnyResult
201
+ )
202
+
203
+ // B4: accepted -> pending rejected
204
+ await async_test(
205
+ 'B4. Cannot flip accepted -> pending',
206
+ () => set_linkedAccountAccess(sdk, adminId, [{ ...pendingFromNonAdmin, status: 'pending' }]),
207
+ { shouldError: true, onError: validatorRejectMatcher }
208
+ )
209
+
210
+ // B11/B12: non-owner PATCH of another user's linkedAccountAccess. Use a NON-empty
211
+ // payload so the rate-limit key is unique from later admin-owned `[]` clears.
212
+ await async_test(
213
+ 'B11/B12. Non-owner PATCH of another user linkedAccountAccess rejected',
214
+ () => sdkNonAdmin.api.users.updateOne(adminId, { linkedAccountAccess: [{ ...pendingFromNonAdmin, status: 'accepted' }] } as any, { replaceObjectFields: true }),
215
+ { shouldError: true, onError: validatorRejectMatcher }
216
+ )
217
+
218
+ // B15: legacy field no longer accepted
219
+ await async_test(
220
+ 'B15. Legacy accountAccessGrantedTo PATCH is rejected',
221
+ () => sdk.api.users.updateOne(adminId, { accountAccessGrantedTo: [nonAdminId] } as any),
222
+ { shouldError: true, onError: validatorRejectMatcher }
223
+ )
224
+
225
+ // B1: Owner can remove a pending entry (re-seed first)
226
+ await clear_linkedAccountAccess(sdk, adminId)
227
+ await sdkNonAdmin.api.users.request_linked_account_access({ targetEmail: adminEmail })
228
+ await wait(undefined, 250)
229
+ await async_test(
230
+ 'B1. Owner can remove a pending entry',
231
+ () => set_linkedAccountAccess(sdk, adminId, []),
232
+ passOnAnyResult
233
+ )
234
+
235
+ // B2: Owner can remove an accepted entry
236
+ await sdkNonAdmin.api.users.request_linked_account_access({ targetEmail: adminEmail })
237
+ await wait(undefined, 250)
238
+ const adminWithPending = await get_user(sdk, adminId)
239
+ const seededPending = (adminWithPending.linkedAccountAccess ?? []).find((e: any) => e.userId === nonAdminId)
240
+ await set_linkedAccountAccess(sdk, adminId, [{ ...seededPending, status: 'accepted' }])
241
+ await async_test(
242
+ 'B2. Owner can remove an accepted entry',
243
+ () => set_linkedAccountAccess(sdk, adminId, []),
244
+ passOnAnyResult
245
+ )
246
+
247
+ // ============================================================
248
+ // C. request_linked_account_access
249
+ // ============================================================
250
+ log_header("C. request_linked_account_access")
251
+
252
+ const unauthedSdk = new Session({ host })
253
+ // Unauthenticated requests can surface either a structured { statusCode: 401 } error or
254
+ // the raw "Unauthenticated" string depending on where in the middleware the rejection fires.
255
+ // Accept either shape but still pin to the 401 semantic.
256
+ const is401Rejection = (e: any) => (
257
+ e?.statusCode === 401
258
+ || (typeof e === 'string' && /^unauthenticated$/i.test(e))
259
+ || /^unauthenticated$/i.test(e?.message || '')
260
+ )
261
+ await async_test(
262
+ 'C1. Unauthenticated request returns 401',
263
+ () => unauthedSdk.api.users.request_linked_account_access({ targetEmail: adminEmail }),
264
+ { shouldError: true, onError: is401Rejection }
265
+ )
266
+
267
+ await async_test(
268
+ 'C2. Non-existent email returns {} (no error)',
269
+ () => sdkNonAdmin.api.users.request_linked_account_access({ targetEmail: `nobody-${RAND()}@tellescope.example` }),
270
+ passOnAnyResult
271
+ )
272
+ await async_test(
273
+ 'C2. No record written for non-existent email',
274
+ () => get_user(sdk, adminId),
275
+ { shouldError: false, onResult: (u: any) => ((u.linkedAccountAccess ?? []) as any[]).length === 0 }
276
+ )
277
+
278
+ // C3: unverified email -> treated as no-match. Verify via admin-created user.
279
+ const unverifiedEmail = `unverified-${RAND()}@tellescope.com`
280
+ let unverifiedUserId = ''
281
+ try {
282
+ const created = await sdk.api.users.createOne({ email: unverifiedEmail, fname: 'Unv', lname: 'User' } as any)
283
+ unverifiedUserId = (created as any).id
284
+ } catch {}
285
+ await async_test(
286
+ 'C3. Unverified email treated as no-match',
287
+ () => sdkNonAdmin.api.users.request_linked_account_access({ targetEmail: unverifiedEmail }),
288
+ passOnAnyResult
289
+ )
290
+ if (unverifiedUserId) {
291
+ await async_test(
292
+ 'C3. No record written on unverified target',
293
+ () => get_user(sdk, unverifiedUserId),
294
+ { shouldError: false, onResult: (u: any) => ((u.linkedAccountAccess ?? []) as any[]).length === 0 }
295
+ )
296
+ try { await sdk.api.users.deleteOne(unverifiedUserId) } catch {}
297
+ }
298
+
299
+ // C5: self-request
300
+ await async_test(
301
+ 'C5. Self-request returns {} and writes nothing',
302
+ () => sdkNonAdmin.api.users.request_linked_account_access({ targetEmail: nonAdminEmail }),
303
+ passOnAnyResult
304
+ )
305
+ await wait(undefined, 250)
306
+ await async_test(
307
+ 'C5. No record written on self-request',
308
+ () => get_user(sdkNonAdmin, nonAdminId),
309
+ { shouldError: false, onResult: (u: any) => ((u.linkedAccountAccess ?? []) as any[]).length === 0 }
310
+ )
311
+
312
+ // C4: valid match
313
+ await async_test(
314
+ 'C4. Valid email request returns {}',
315
+ () => sdkNonAdmin.api.users.request_linked_account_access({ targetEmail: adminEmail }),
316
+ passOnAnyResult
317
+ )
318
+ await wait(undefined, 250)
319
+ await async_test(
320
+ 'C4. Pending entry written with requester snapshot',
321
+ () => get_user(sdk, adminId),
322
+ { shouldError: false, onResult: (u: any) => {
323
+ const e = (u.linkedAccountAccess ?? []).find((x: any) => x.userId === nonAdminId)
324
+ if (!e) return false
325
+ const expiresOk = (new Date(e.requestExpiresAt).getTime() - Date.now()) > 6 * 24 * 60 * 60 * 1000
326
+ return e.status === 'pending' && e.email === nonAdminEmail && !!e.createdAt && expiresOk
327
+ }}
328
+ )
329
+
330
+ // C6: idempotent
331
+ const adminPre = await get_user(sdk, adminId)
332
+ const preLen = ((adminPre.linkedAccountAccess ?? []) as any[]).length
333
+ await sdkNonAdmin.api.users.request_linked_account_access({ targetEmail: adminEmail })
334
+ await wait(undefined, 250)
335
+ await async_test(
336
+ 'C6. Duplicate request inside window does not duplicate the entry',
337
+ () => get_user(sdk, adminId),
338
+ { shouldError: false, onResult: (u: any) => ((u.linkedAccountAccess ?? []) as any[]).length === preLen }
339
+ )
340
+
341
+ // C7: existing accepted -> no-op
342
+ const adminBeforeAccept = await get_user(sdk, adminId)
343
+ const pendingEntry = (adminBeforeAccept.linkedAccountAccess ?? []).find((e: any) => e.userId === nonAdminId)
344
+ await set_linkedAccountAccess(sdk, adminId, [{ ...pendingEntry, status: 'accepted' }])
345
+ await wait(undefined, 250)
346
+ await sdkNonAdmin.api.users.request_linked_account_access({ targetEmail: adminEmail })
347
+ await wait(undefined, 250)
348
+ await async_test(
349
+ 'C7. Re-requesting on accepted entry is a no-op',
350
+ () => get_user(sdk, adminId),
351
+ { shouldError: false, onResult: (u: any) => {
352
+ const entries = (u.linkedAccountAccess ?? []) as any[]
353
+ const e = entries.find((x: any) => x.userId === nonAdminId)
354
+ return entries.length === 1 && e?.status === 'accepted'
355
+ }}
356
+ )
357
+
358
+ // Reset
359
+ await clear_linkedAccountAccess(sdk, adminId)
360
+
361
+ // C9: email case-insensitivity (whitespace is rejected at the schema validator;
362
+ // case-insensitive matching happens after emailValidator lowercases the input).
363
+ await sdkNonAdmin.api.users.request_linked_account_access({ targetEmail: adminEmail.toUpperCase() })
364
+ await wait(undefined, 250)
365
+ await async_test(
366
+ 'C9. Email case-insensitive matching',
367
+ () => get_user(sdk, adminId),
368
+ { shouldError: false, onResult: (u: any) => ((u.linkedAccountAccess ?? []) as any[]).some((e: any) => e.userId === nonAdminId) }
369
+ )
370
+
371
+ // C10 is the request_linked_account_access rate-limit test. Because it exhausts admin's
372
+ // `request-linked-${adminId}` counter for 60s, and E5/E6/E7 setup needs admin to send a
373
+ // single request_linked_account_access (to be the source-side in the role-flipped lockout
374
+ // tests), C10 is intentionally relocated to the bottom of the suite (after I4) so no
375
+ // downstream test depends on admin's request_linked quota.
376
+
377
+ // ============================================================
378
+ // D. get_linked_accounts
379
+ // ============================================================
380
+ log_header("D. get_linked_accounts")
381
+
382
+ await clear_linkedAccountAccess(sdk, adminId)
383
+ await sdkNonAdmin.api.users.request_linked_account_access({ targetEmail: adminEmail })
384
+ await wait(undefined, 250)
385
+ const adminWithReq = await get_user(sdk, adminId)
386
+ const pendingForNonAdmin = (adminWithReq.linkedAccountAccess ?? []).find((e: any) => e.userId === nonAdminId)
387
+
388
+ await async_test(
389
+ 'D2. Pending entry not returned',
390
+ () => sdkNonAdmin.api.users.get_linked_accounts(),
391
+ { shouldError: false, onResult: (r: any) => !((r.linkedAccounts ?? []) as any[]).some(a => a.id === adminId) }
392
+ )
393
+
394
+ await set_linkedAccountAccess(sdk, adminId, [{ ...pendingForNonAdmin, status: 'accepted' }])
395
+ await wait(undefined, 250)
396
+
397
+ await async_test(
398
+ 'D1. Accepted entry is returned',
399
+ () => sdkNonAdmin.api.users.get_linked_accounts(),
400
+ { shouldError: false, onResult: (r: any) => ((r.linkedAccounts ?? []) as any[]).some(a => a.id === adminId) }
401
+ )
402
+ await async_test(
403
+ 'D5. Returned row has expected identity fields',
404
+ () => sdkNonAdmin.api.users.get_linked_accounts(),
405
+ { shouldError: false, onResult: (r: any) => {
406
+ const row = ((r.linkedAccounts ?? []) as any[]).find(a => a.id === adminId)
407
+ return !!row && typeof row.email === 'string' && row.email.length > 0 && typeof row.orgName === 'string' && typeof row.requiresMFA === 'boolean'
408
+ }}
409
+ )
410
+ await async_test(
411
+ 'D3. Self is excluded',
412
+ () => sdkNonAdmin.api.users.get_linked_accounts(),
413
+ { shouldError: false, onResult: (r: any) => !((r.linkedAccounts ?? []) as any[]).some(a => a.id === nonAdminId) }
414
+ )
415
+ await async_test(
416
+ 'D6. Empty result for caller with no grants directed at them',
417
+ () => sdk.api.users.get_linked_accounts(),
418
+ { shouldError: false, onResult: (r: any) => Array.isArray(r.linkedAccounts) && r.linkedAccounts.length === 0 }
419
+ )
420
+
421
+ // ============================================================
422
+ // E. switch_account — grant + accessibility
423
+ // ============================================================
424
+ log_header("E. switch_account — grant + accessibility")
425
+
426
+ await clear_linkedAccountAccess(sdk, adminId)
427
+ await async_test(
428
+ 'E1. No entry -> 403',
429
+ () => sdkNonAdmin.api.users.switch_account({ targetUserId: adminId }),
430
+ { shouldError: true, onError: (e: any) => e.statusCode === 403 || (e.message || '').includes('not granted') }
431
+ )
432
+
433
+ await sdkNonAdmin.api.users.request_linked_account_access({ targetEmail: adminEmail })
434
+ await wait(undefined, 250)
435
+ await async_test(
436
+ 'E2. Only pending -> 403',
437
+ () => sdkNonAdmin.api.users.switch_account({ targetUserId: adminId }),
438
+ { shouldError: true, onError: (e: any) => e.statusCode === 403 || (e.message || '').includes('not granted') }
439
+ )
440
+
441
+ await async_test(
442
+ 'E3. Self-switch -> 400',
443
+ () => sdkNonAdmin.api.users.switch_account({ targetUserId: nonAdminId }),
444
+ { shouldError: true, onError: (e: any) => e.statusCode === 400 || (e.message || '').includes('own account') }
445
+ )
446
+
447
+ await async_test(
448
+ 'E4. Nonexistent target -> 404',
449
+ () => sdkNonAdmin.api.users.switch_account({ targetUserId: '000000000000000000000000' }),
450
+ { shouldError: true, onError: (e: any) => e.statusCode === 404 || (e.message || '').includes('not found') }
451
+ )
452
+
453
+ // E9. Malformed targetUserId -> 400 (mongoIdStringRequired schema validator)
454
+ await async_test(
455
+ 'E9. Malformed targetUserId -> 400',
456
+ () => sdkNonAdmin.api.users.switch_account({ targetUserId: 'not-a-mongo-id' as any }),
457
+ { shouldError: true, onError: (e: any) => e.statusCode === 400 || /(invalid|mongoId|parsing|format)/i.test(e.message || '') }
458
+ )
459
+
460
+ // E5/E6/E7. Locked target -> 401. Flip roles: admin (only user who can write locked* fields) is the
461
+ // SOURCE, nonAdmin is the TARGET. nonAdmin grants admin access first.
462
+ // Run BEFORE E10's rate-limit exhaustion so admin's switch counter is still fresh here.
463
+ await clear_linkedAccountAccess(sdkNonAdmin, nonAdminId)
464
+ await sdk.api.users.request_linked_account_access({ targetEmail: nonAdminEmail })
465
+ await wait(undefined, 250)
466
+ const nonAdminAfterReq = await get_user(sdkNonAdmin, nonAdminId)
467
+ const pendingFromAdmin = (nonAdminAfterReq.linkedAccountAccess ?? []).find((e: any) => e.userId === adminId)
468
+ if (pendingFromAdmin) {
469
+ await set_linkedAccountAccess(sdkNonAdmin, nonAdminId, [{ ...pendingFromAdmin, status: 'accepted' }])
470
+ await wait(undefined, 250)
471
+ }
472
+
473
+ // E5: lockedOutUntil in the future
474
+ await sdk.api.users.updateOne(nonAdminId, { lockedOutUntil: Date.now() + 60_000 } as any)
475
+ await async_test(
476
+ 'E5. Target with lockedOutUntil > now -> 401',
477
+ () => sdk.api.users.switch_account({ targetUserId: nonAdminId }),
478
+ { shouldError: true, onError: (e: any) => e.statusCode === 401 || /(locked|not accessible)/i.test(e.message || '') }
479
+ )
480
+
481
+ // E6: lockedOutUntil === 0 (indefinite lock)
482
+ await sdk.api.users.updateOne(nonAdminId, { lockedOutUntil: 0 } as any)
483
+ await async_test(
484
+ 'E6. Target with lockedOutUntil === 0 -> 401',
485
+ () => sdk.api.users.switch_account({ targetUserId: nonAdminId }),
486
+ { shouldError: true, onError: (e: any) => e.statusCode === 401 || /(locked|not accessible)/i.test(e.message || '') }
487
+ )
488
+
489
+ // E7: failedLoginAttempts >= 10
490
+ await sdk.api.users.updateOne(nonAdminId, { lockedOutUntil: -1, failedLoginAttempts: 10 } as any)
491
+ await async_test(
492
+ 'E7. Target with failedLoginAttempts >= 10 -> 401',
493
+ () => sdk.api.users.switch_account({ targetUserId: nonAdminId }),
494
+ { shouldError: true, onError: (e: any) => e.statusCode === 401 || /(locked|not accessible|failed login)/i.test(e.message || '') }
495
+ )
496
+
497
+ // Restore nonAdmin to a healthy state. Setting lockedOutUntil to 0/future in E5/E6 triggered
498
+ // deauthenticate_user(nonAdminId) via routing.ts:2742-2752, writing `deauthenticated-${id}`
499
+ // to cache with the current timestamp. is_logged_in rejects any token whose iat falls within
500
+ // the 1s slack window after that timestamp — so a re-auth too quickly afterward produces a
501
+ // token that gets immediately invalidated. Wait > 1s past E6's deauth before re-authing.
502
+ await sdk.api.users.updateOne(nonAdminId, { lockedOutUntil: -1, failedLoginAttempts: 0 } as any)
503
+ await wait(undefined, 1500)
504
+ await sdkNonAdmin.authenticate(NON_ADMIN_EMAIL, NON_ADMIN_PASSWORD)
505
+ await clear_linkedAccountAccess(sdkNonAdmin, nonAdminId)
506
+
507
+ // E10. switch_account rate limit (20/min). Each failed switch consumes a quota slot
508
+ // (rate-limit check runs first; source token not invalidated). E5-E7 above already burned
509
+ // 3 slots from admin's switch-account counter, so prime 17 more to reach the limit, then
510
+ // assert the next call is 429. Keep this LAST in E — exhausts admin's switch counter.
511
+ for (let i = 0; i < 17; i++) {
512
+ try { await sdk.api.users.switch_account({ targetUserId: '000000000000000000000000' }) } catch {}
513
+ }
514
+ await async_test(
515
+ 'E10. Switch beyond 20/min is rate-limited (429)',
516
+ () => sdk.api.users.switch_account({ targetUserId: '000000000000000000000000' }),
517
+ { shouldError: true, onError: (e: any) => e.statusCode === 429 || (e.message || '').toLowerCase().includes('rate') }
518
+ )
519
+
520
+ // ============================================================
521
+ // F. switch_account — enforceMFA gap
522
+ // ============================================================
523
+ log_header("F. switch_account — enforceMFA gap")
524
+
525
+ // Set up accepted grant: nonAdmin requests, admin accepts. (E section cleared admin's array.)
526
+ await clear_linkedAccountAccess(sdk, adminId)
527
+ await sdkNonAdmin.api.users.request_linked_account_access({ targetEmail: adminEmail })
528
+ await wait(undefined, 250)
529
+ const adminForF = await get_user(sdk, adminId)
530
+ const pendingForF = (adminForF.linkedAccountAccess ?? []).find((e: any) => e.userId === nonAdminId)
531
+ if (pendingForF) {
532
+ await set_linkedAccountAccess(sdk, adminId, [{ ...pendingForF, status: 'accepted' }])
533
+ await wait(undefined, 250)
534
+ }
535
+
536
+ // Enable enforceMFA on the test business; admin (target) currently has no MFA configured.
537
+ await sdk.api.organizations.updateOne(adminBusinessId, { enforceMFA: true } as any)
538
+ await wait(undefined, 250)
539
+
540
+ // F1: target with no MFA but org enforces -> 403
541
+ await async_test(
542
+ 'F1. enforceMFA on org + target MFA not configured -> 403',
543
+ () => sdkNonAdmin.api.users.switch_account({ targetUserId: adminId }),
544
+ { shouldError: true, onError: (e: any) => e.statusCode === 403 || /(MFA configuration|enforceMFA)/i.test(e.message || '') }
545
+ )
546
+
547
+ // F2: admin configures MFA, switch now succeeds — but the switched JWT has requiresMFA: true.
548
+ await sdk.api.users.configure_MFA({ disable: false })
549
+ await wait(undefined, 250)
550
+ await async_test(
551
+ 'F2. After target configures MFA, switch succeeds and switched JWT has requiresMFA: true',
552
+ () => sdkNonAdmin.api.users.switch_account({ targetUserId: adminId }),
553
+ { shouldError: false, onResult: (r: any) => {
554
+ const decoded = decode_jwt(r.authToken)
555
+ return !!r.authToken && decoded?.requiresMFA === true
556
+ }}
557
+ )
558
+
559
+ // Teardown F: revert enforceMFA first (configure_MFA(disable=true) refuses while enforced),
560
+ // then disable MFA on admin. Re-auth nonAdmin since its source token was invalidated by F2's switch.
561
+ await sdk.api.organizations.updateOne(adminBusinessId, { enforceMFA: false } as any)
562
+ await wait(undefined, 250)
563
+ try { await sdk.api.users.configure_MFA({ disable: true }) } catch {}
564
+ await sdkNonAdmin.authenticate(NON_ADMIN_EMAIL, NON_ADMIN_PASSWORD)
565
+
566
+ // ============================================================
567
+ // G. switch_account — JWT + audit
568
+ // ============================================================
569
+ log_header("G. switch_account — JWT + audit")
570
+
571
+ // Set up an accepted grant. clear_linkedAccountAccess removes F's accepted entry, which
572
+ // writes the `grant-revoked-${adminId}-${nonAdminId}` cache key. is_logged_in compares that
573
+ // key against the new JWT's iat with a 1s slack (accounts for JWT iat second-rounding), so
574
+ // we must ensure the new grant is minted well past that window or the freshly-switched
575
+ // token gets rejected as if it were a pre-revocation token. Wait > 1s between the cleanup
576
+ // and the new switch.
577
+ await clear_linkedAccountAccess(sdk, adminId)
578
+ await wait(undefined, 1500)
579
+ await sdkNonAdmin.api.users.request_linked_account_access({ targetEmail: adminEmail })
580
+ await wait(undefined, 250)
581
+ const adminPendingState = await get_user(sdk, adminId)
582
+ const pendingNA = (adminPendingState.linkedAccountAccess ?? []).find((e: any) => e.userId === nonAdminId)
583
+ await set_linkedAccountAccess(sdk, adminId, [{ ...pendingNA, status: 'accepted' }])
584
+ await wait(undefined, 250)
585
+
586
+ let switchedToken = ''
587
+ let switchedUser: any = null
588
+ await async_test(
589
+ 'G0. Switch succeeds when accepted grant exists',
590
+ () => sdkNonAdmin.api.users.switch_account({ targetUserId: adminId }),
591
+ { shouldError: false, onResult: (r: any) => {
592
+ switchedToken = r.authToken
593
+ switchedUser = r.user
594
+ return typeof r.authToken === 'string' && r.authToken.length > 0 && r.user?.id === adminId
595
+ }}
596
+ )
597
+
598
+ const decoded = decode_jwt(switchedToken)
599
+ assert(!!decoded, 'JWT decode failed', 'G1. JWT decoded')
600
+ assert(decoded?.id === adminId, `JWT.id ${decoded?.id} != expected ${adminId}`, 'G1. JWT.id == target')
601
+ assert(decoded?.actorUserId === nonAdminId, `JWT.actorUserId mismatch`, 'G1. JWT.actorUserId == source')
602
+ assert(decoded?.actorEmail === nonAdminEmail, `JWT.actorEmail mismatch`, 'G1. JWT.actorEmail')
603
+ assert(decoded?.actorBusinessId === nonAdminBusinessId, `JWT.actorBusinessId mismatch`, 'G1. JWT.actorBusinessId')
604
+
605
+ await async_test(
606
+ 'G2. Pre-switch nonAdmin token is invalidated',
607
+ () => sdkNonAdmin.test_authenticated(),
608
+ { shouldError: true, onError: () => true }
609
+ )
610
+
611
+ const switchedSdk = new Session({ host })
612
+ switchedSdk.setAuthToken(switchedToken)
613
+ switchedSdk.setUserInfo(switchedUser)
614
+ await async_test(
615
+ 'G3. Switched token authenticates',
616
+ () => switchedSdk.test_authenticated(),
617
+ { shouldError: false, onResult: (r: any) => r === 'Authenticated!' }
618
+ )
619
+
620
+ await wait(undefined, 500)
621
+ await async_test(
622
+ 'G4. user_logs has account_switch event with full info',
623
+ () => sdk.api.user_logs.getOne({ resourceId: adminId, resource: 'users', action: 'update' } as any),
624
+ { shouldError: false, onResult: (log: any) => {
625
+ const info = log?.info ?? {}
626
+ return log?.userId === nonAdminId
627
+ && info.event === 'account_switch'
628
+ && info.sourceUserId === nonAdminId
629
+ && info.sourceEmail === nonAdminEmail
630
+ && info.sourceBusinessId === nonAdminBusinessId
631
+ && info.targetUserId === adminId
632
+ && info.targetEmail === adminEmail
633
+ && info.targetBusinessId === adminBusinessId
634
+ }}
635
+ )
636
+
637
+ // G5: downstream user_log under switched session carries actorUserId.
638
+ // Capture original fname so we can restore it during cleanup — downstream tests
639
+ // (e.g. Calendar RSVPs) compare userInfo.fname against server-side values.
640
+ const originalAdminFname = sdk.userInfo.fname
641
+ await switchedSdk.api.users.updateOne(adminId, { fname: `Switched-${RAND()}` } as any)
642
+ await wait(undefined, 500)
643
+ await async_test(
644
+ 'G5. Downstream user_log under switched session has actorUserId',
645
+ () => sdk.api.user_logs.getSome({ filter: { resourceId: adminId, resource: 'users', action: 'update' } } as any),
646
+ { shouldError: false, onResult: (logs: any[]) => (logs ?? []).some((l: any) => l.actorUserId === nonAdminId && l.userId === adminId) }
647
+ )
648
+
649
+ // G6. PHI-adjacent collection: create an enduser through the switched session and assert
650
+ // the auto-emitted CRUD user_log carries actorUserId (exercises routing.ts inline insert
651
+ // path, not just storeUserLog or the users-collection update path).
652
+ let g6EnduserId = ''
653
+ try {
654
+ const ce = await switchedSdk.api.endusers.createOne({ fname: 'Switch', lname: 'Test', email: `switch-test-${RAND()}@tellescope.example` } as any)
655
+ g6EnduserId = (ce as any).id
656
+ } catch (e) { /* assertion below will surface */ }
657
+ await wait(undefined, 500)
658
+ await async_test(
659
+ 'G6. Downstream enduser create under switched session has actorUserId',
660
+ () => sdk.api.user_logs.getSome({ filter: { resource: 'endusers', action: 'create' } } as any),
661
+ { shouldError: false, onResult: (logs: any[]) => (logs ?? []).some((l: any) => l.actorUserId === nonAdminId && l.userId === adminId && (g6EnduserId ? l.resourceId === g6EnduserId : true)) }
662
+ )
663
+ if (g6EnduserId) { try { await sdk.api.endusers.deleteOne(g6EnduserId) } catch {} }
664
+
665
+ // G7. Cross-org boundary assertion: the switched JWT operates in the TARGET's business.
666
+ // If a future change ever gates cross-org switching, this assertion will fire.
667
+ assert(decoded?.businessId === adminBusinessId, `JWT.businessId mismatch (got ${decoded?.businessId}, expected ${adminBusinessId})`, 'G7. JWT.businessId == target businessId')
668
+
669
+ // ============================================================
670
+ // H. Real-time revocation
671
+ // ============================================================
672
+ log_header("H. Real-time revocation")
673
+
674
+ await async_test(
675
+ 'H1. Baseline: switched session reads OK',
676
+ () => switchedSdk.test_authenticated(),
677
+ { shouldError: false, onResult: (r: any) => r === 'Authenticated!' }
678
+ )
679
+
680
+ // Revoke
681
+ await clear_linkedAccountAccess(sdk, adminId)
682
+ await wait(undefined, 750)
683
+
684
+ await async_test(
685
+ 'H2. Switched session 401 after revoke',
686
+ () => switchedSdk.test_authenticated(),
687
+ { shouldError: true, onError: is401Rejection }
688
+ )
689
+
690
+ await async_test(
691
+ 'H3. Owner own session still works (no over-broad invalidation)',
692
+ () => sdk.test_authenticated(),
693
+ { shouldError: false, onResult: (r: any) => r === 'Authenticated!' }
694
+ )
695
+
696
+ // H6. After revoke, a brand-new switch_account attempt is also rejected (covers the
697
+ // net-new path, complementing H2 which covers the already-minted session path).
698
+ // nonAdmin's source token from G0 was invalidated by that successful switch; re-auth first.
699
+ await sdkNonAdmin.authenticate(NON_ADMIN_EMAIL, NON_ADMIN_PASSWORD)
700
+ await async_test(
701
+ 'H6. New switch_account after revoke -> 403',
702
+ () => sdkNonAdmin.api.users.switch_account({ targetUserId: adminId }),
703
+ { shouldError: true, onError: (e: any) => e.statusCode === 403 || (e.message || '').includes('not granted') }
704
+ )
705
+
706
+ // H5: reject of pending entry does not write a stale revocation key
707
+ await sdkNonAdmin.authenticate(NON_ADMIN_EMAIL, NON_ADMIN_PASSWORD)
708
+ await sdkNonAdmin.api.users.request_linked_account_access({ targetEmail: adminEmail })
709
+ await wait(undefined, 250)
710
+ await clear_linkedAccountAccess(sdk, adminId) // reject pending
711
+ await wait(undefined, 250)
712
+ await sdkNonAdmin.api.users.request_linked_account_access({ targetEmail: adminEmail })
713
+ await wait(undefined, 250)
714
+ const state = await get_user(sdk, adminId)
715
+ const newPending = (state.linkedAccountAccess ?? []).find((e: any) => e.userId === nonAdminId)
716
+ await set_linkedAccountAccess(sdk, adminId, [{ ...newPending, status: 'accepted' }])
717
+ await wait(undefined, 500)
718
+ await async_test(
719
+ 'H5. New switched session works after a prior reject (no stale revocation)',
720
+ () => sdkNonAdmin.api.users.switch_account({ targetUserId: adminId }),
721
+ { shouldError: false, onResult: (r: any) => typeof r.authToken === 'string' && r.authToken.length > 0 }
722
+ )
723
+ await sdkNonAdmin.authenticate(NON_ADMIN_EMAIL, NON_ADMIN_PASSWORD)
724
+
725
+ // ============================================================
726
+ // O. Org-toggle gating (accountSwitchingEnabled)
727
+ // ============================================================
728
+ log_header("O. Org-toggle gating")
729
+
730
+ // Pre-stage an accepted entry while the toggle is ON (it currently is).
731
+ // Wait > 1s after the cleanup so the (adminId, nonAdminId) revocation key from H sits
732
+ // clearly before the new switch's iat — is_logged_in's 1s slack would otherwise reject
733
+ // freshly minted tokens as if they were pre-revocation.
734
+ await clear_linkedAccountAccess(sdk, adminId)
735
+ await wait(undefined, 1500)
736
+ await sdkNonAdmin.api.users.request_linked_account_access({ targetEmail: adminEmail })
737
+ await wait(undefined, 250)
738
+ const oSeedState = await get_user(sdk, adminId)
739
+ const oPending = (oSeedState.linkedAccountAccess ?? []).find((e: any) => e.userId === nonAdminId)
740
+ if (oPending) {
741
+ await set_linkedAccountAccess(sdk, adminId, [{ ...oPending, status: 'accepted' }])
742
+ await wait(undefined, 250)
743
+ }
744
+
745
+ // Toggle OFF.
746
+ await sdk.api.organizations.updateOne(adminBusinessId, { accountSwitchingEnabled: false } as any)
747
+ await wait(undefined, 250)
748
+
749
+ // O1. request_linked_account_access silently no-ops while toggle is off.
750
+ const oBefore = await get_user(sdk, adminId)
751
+ const oBeforeLen = ((oBefore.linkedAccountAccess ?? []) as any[]).length
752
+ await async_test(
753
+ 'O1. request_linked_account_access returns {} while toggle is off',
754
+ () => sdkNonAdmin.api.users.request_linked_account_access({ targetEmail: adminEmail }),
755
+ { shouldError: false, onResult: () => true }
756
+ )
757
+ await wait(undefined, 250)
758
+ await async_test(
759
+ 'O1. No new record written while toggle is off',
760
+ () => get_user(sdk, adminId),
761
+ { shouldError: false, onResult: (u: any) => ((u.linkedAccountAccess ?? []) as any[]).length === oBeforeLen }
762
+ )
763
+
764
+ // O2. switch_account on a pre-existing accepted grant -> 403 while toggle is off.
765
+ await async_test(
766
+ 'O2. switch_account -> 403 while target org has toggle off',
767
+ () => sdkNonAdmin.api.users.switch_account({ targetUserId: adminId }),
768
+ { shouldError: true, onError: (e: any) => e.statusCode === 403 || /(organization has not enabled|switching)/i.test(e.message || '') }
769
+ )
770
+
771
+ // O5. With the toggle off, the SOURCE-org check fires first (before the target check),
772
+ // so switch_account responds with "Your organization has not enabled..." rather than
773
+ // "Target organization has not enabled...". Single-org fixture means source==target here;
774
+ // the error-message assertion is what proves the source-side gate is actually firing
775
+ // (without it, O2 would have passed under the old target-only implementation too).
776
+ await async_test(
777
+ 'O5. switch_account error message names the actor org (source-side check fires)',
778
+ () => sdkNonAdmin.api.users.switch_account({ targetUserId: adminId }),
779
+ { shouldError: true, onError: (e: any) => /your organization/i.test(e.message || '') }
780
+ )
781
+
782
+ // Toggle ON again.
783
+ await sdk.api.organizations.updateOne(adminBusinessId, { accountSwitchingEnabled: true } as any)
784
+ await wait(undefined, 250)
785
+
786
+ // O3. switch_account now succeeds (same accepted grant as before).
787
+ await async_test(
788
+ 'O3. switch_account succeeds once toggle is re-enabled',
789
+ () => sdkNonAdmin.api.users.switch_account({ targetUserId: adminId }),
790
+ { shouldError: false, onResult: (r: any) => typeof r.authToken === 'string' && r.authToken.length > 0 }
791
+ )
792
+ // nonAdmin's source token was invalidated by the switch; re-auth for subsequent tests.
793
+ await sdkNonAdmin.authenticate(NON_ADMIN_EMAIL, NON_ADMIN_PASSWORD)
794
+
795
+ // O4. request_linked_account_access writes a record once toggle is re-enabled.
796
+ await clear_linkedAccountAccess(sdk, adminId)
797
+ await sdkNonAdmin.api.users.request_linked_account_access({ targetEmail: adminEmail })
798
+ await wait(undefined, 250)
799
+ await async_test(
800
+ 'O4. request_linked_account_access writes a pending entry once toggle is on',
801
+ () => get_user(sdk, adminId),
802
+ { shouldError: false, onResult: (u: any) => ((u.linkedAccountAccess ?? []) as any[]).some((e: any) => e.userId === nonAdminId && e.status === 'pending') }
803
+ )
804
+
805
+ // ============================================================
806
+ // I. Cross-cutting / regressions
807
+ // ============================================================
808
+ log_header("I. Cross-cutting / regressions")
809
+
810
+ await async_test(
811
+ 'I1. Legacy users.updateOne({ accountAccessGrantedTo: [...] }) rejected',
812
+ () => sdk.api.users.updateOne(adminId, { accountAccessGrantedTo: [nonAdminId] } as any),
813
+ { shouldError: true, onError: (e: any) => e.statusCode === 400 || /(accountAccessGrantedTo|legacy|replaced|No updates provided)/i.test(e.message || '') }
814
+ )
815
+
816
+ await async_test(
817
+ 'I2. After full revoke, get_linked_accounts no longer shows A',
818
+ async () => {
819
+ await clear_linkedAccountAccess(sdk, adminId)
820
+ await wait(undefined, 250)
821
+ const r = await sdkNonAdmin.api.users.get_linked_accounts()
822
+ return ((r.linkedAccounts ?? []) as any[]).find((a: any) => a.id === adminId)
823
+ },
824
+ { shouldError: false, onResult: (r: any) => r === undefined }
825
+ )
826
+
827
+ // I4. Accepted-grant expiration semantics — lock in the chosen behavior.
828
+ // The switch handler does NOT check requestExpiresAt for accepted entries: that field
829
+ // only governs the *pending* TTL. If this changes, this test will fail; that's the
830
+ // signal to make the behavior choice deliberately. (Simulating an actually-expired
831
+ // accepted grant requires either waiting 7 days or mutating requestExpiresAt — the
832
+ // schema validator blocks the latter, so we lock in the behavior by inspection.)
833
+ // I2 above did clear_linkedAccountAccess → wait > 1s past the resulting revocation key
834
+ // before re-granting; otherwise is_logged_in's 1s slack rejects the new switched token.
835
+ await wait(undefined, 1500)
836
+ await sdkNonAdmin.api.users.request_linked_account_access({ targetEmail: adminEmail })
837
+ await wait(undefined, 250)
838
+ const i4State = await get_user(sdk, adminId)
839
+ const i4Pending = (i4State.linkedAccountAccess ?? []).find((e: any) => e.userId === nonAdminId)
840
+ if (i4Pending) {
841
+ await set_linkedAccountAccess(sdk, adminId, [{ ...i4Pending, status: 'accepted' }])
842
+ await wait(undefined, 250)
843
+ }
844
+ const i4Accepted = await get_user(sdk, adminId)
845
+ const i4Entry = (i4Accepted.linkedAccountAccess ?? []).find((e: any) => e.userId === nonAdminId)
846
+ await async_test(
847
+ 'I4. Switch succeeds on a future-dated accepted grant (expiration-ignored semantics locked in by inspection)',
848
+ () => sdkNonAdmin.api.users.switch_account({ targetUserId: adminId }),
849
+ { shouldError: false, onResult: (r: any) => (
850
+ typeof r.authToken === 'string'
851
+ && r.authToken.length > 0
852
+ && !!i4Entry
853
+ && new Date(i4Entry.requestExpiresAt).getTime() > Date.now()
854
+ )}
855
+ )
856
+ // Re-auth nonAdmin since the switch invalidated its source token
857
+ await sdkNonAdmin.authenticate(NON_ADMIN_EMAIL, NON_ADMIN_PASSWORD)
858
+
859
+ // ============================================================
860
+ // K. Actor-identity model (chained-switch + switch-back semantics)
861
+ // ============================================================
862
+ // Premise: the real operator is always the actor (session.actorUserId || session.id).
863
+ // Grant checks, audit attribution, and request authorship all derive from realActor —
864
+ // never from the proxy identity. Verified here via:
865
+ // - get_linked_accounts returns the actor's grants + the actor's own account (switch-back).
866
+ // - Validator rejects ALL linkedAccountAccess writes from switched sessions.
867
+ // - request_linked_account_access uses actor's email for self-check (proxy-email no-op
868
+ // would otherwise create a self-targeted pending entry).
869
+ // - switch_account back to the actor mints a JWT with actor* claims cleared.
870
+ // - Audit log for switch-back records event=account_switch_back with proxySessionId.
871
+ log_header("K. Actor-identity model")
872
+
873
+ // Seed an accepted grant so nonAdmin can switch into admin. Wait > 1s past the prior
874
+ // clear_linkedAccountAccess so is_logged_in's iat-vs-revoked-key slack doesn't trip.
875
+ await clear_linkedAccountAccess(sdk, adminId)
876
+ await wait(undefined, 1500)
877
+ await sdkNonAdmin.api.users.request_linked_account_access({ targetEmail: adminEmail })
878
+ await wait(undefined, 250)
879
+ const kSeedState = await get_user(sdk, adminId)
880
+ const kPending = (kSeedState.linkedAccountAccess ?? []).find((e: any) => e.userId === nonAdminId)
881
+ await set_linkedAccountAccess(sdk, adminId, [{ ...kPending, status: 'accepted' }])
882
+ await wait(undefined, 250)
883
+
884
+ // K1. Establish a switched session: nonAdmin → admin.
885
+ let kSwitchedToken = ''
886
+ let kSwitchedUser: any = null
887
+ await async_test(
888
+ 'K1. nonAdmin switches into admin (sets up actor-identity scenario)',
889
+ () => sdkNonAdmin.api.users.switch_account({ targetUserId: adminId }),
890
+ { shouldError: false, onResult: (r: any) => {
891
+ kSwitchedToken = r.authToken
892
+ kSwitchedUser = r.user
893
+ return typeof r.authToken === 'string' && r.authToken.length > 0 && r.user?.id === adminId
894
+ }}
895
+ )
896
+ const kSwitchedSdk = new Session({ host })
897
+ kSwitchedSdk.setAuthToken(kSwitchedToken)
898
+ kSwitchedSdk.setUserInfo(kSwitchedUser)
899
+
900
+ // K2. get_linked_accounts from a switched session returns the actor's own account first
901
+ // (the switch-back entry) and excludes the current proxy identity (admin) — querying
902
+ // against realActor=nonAdmin would normally surface admin (it has nonAdmin: accepted),
903
+ // but the caller IS already admin so switching there would no-op. K8b separately covers
904
+ // the case where the list DOES include actor-grants that aren't the current proxy.
905
+ await async_test(
906
+ 'K2. get_linked_accounts from switched session: actor first, current proxy excluded',
907
+ () => kSwitchedSdk.api.users.get_linked_accounts(),
908
+ { shouldError: false, onResult: (r: any) => {
909
+ const accounts = (r?.linkedAccounts ?? []) as any[]
910
+ if (accounts.length === 0) return false
911
+ if (accounts[0].id !== nonAdminId) return false
912
+ // Current proxy (admin) must NOT appear, even though admin granted nonAdmin.
913
+ const hasSelfProxy = accounts.some((a: any) => a.id === adminId)
914
+ return !hasSelfProxy
915
+ }}
916
+ )
917
+
918
+ // K3. Validator rejects ALL linkedAccountAccess PATCHes from a switched session — even
919
+ // ones the proxy identity (admin = ownerId) would normally be authorized to make. Closes
920
+ // the self-approval / silent-revoke hole. Use the marker-tag helper to ensure a unique
921
+ // payload (avoid the 3/30s identical-update rate limit colliding with this case).
922
+ await async_test(
923
+ 'K3. Validator rejects linkedAccountAccess PATCH from switched session',
924
+ () => set_linkedAccountAccess(kSwitchedSdk, adminId, [{ ...kPending, status: 'accepted' }]),
925
+ { shouldError: true, onError: (e: any) => (
926
+ e.statusCode === 400
927
+ || /(switched session|actorUserId|while acting)/i.test(e.message || '')
928
+ )}
929
+ )
930
+
931
+ // K4. request_linked_account_access from switched session uses the actor's email for the
932
+ // self-check. Calling with targetEmail = nonAdminEmail (the actor's own email) → silent
933
+ // {} and NO write. Old behavior would have used session.email=adminEmail for the self-
934
+ // check, mismatched, looked up nonAdmin, and created a pending entry on nonAdmin.
935
+ await async_test(
936
+ 'K4. request_linked_account_access from switched session uses actor email for self-check',
937
+ () => kSwitchedSdk.api.users.request_linked_account_access({ targetEmail: nonAdminEmail }),
938
+ passOnAnyResult,
939
+ )
940
+ await wait(undefined, 250)
941
+ await async_test(
942
+ 'K4. No pending entry created on actor record (actor identity is the requester)',
943
+ () => get_user(sdk, nonAdminId),
944
+ { shouldError: false, onResult: (u: any) => !((u.linkedAccountAccess ?? []) as any[]).length }
945
+ )
946
+
947
+ // K5. switch-back: nonAdmin (acting as admin) returns to nonAdmin. No grant lookup
948
+ // (target === realActor); resulting JWT has all actor* claims cleared.
949
+ let kBackToken = ''
950
+ await async_test(
951
+ 'K5. Switch-back to actor succeeds (no grant lookup required)',
952
+ () => kSwitchedSdk.api.users.switch_account({ targetUserId: nonAdminId }),
953
+ { shouldError: false, onResult: (r: any) => {
954
+ kBackToken = r.authToken
955
+ return typeof r.authToken === 'string' && r.authToken.length > 0 && r.user?.id === nonAdminId
956
+ }}
957
+ )
958
+ const kBackDecoded = decode_jwt(kBackToken)
959
+ assert(kBackDecoded?.id === nonAdminId, `JWT.id ${kBackDecoded?.id} != ${nonAdminId}`, 'K5. JWT.id == actor')
960
+ assert(!kBackDecoded?.actorUserId, `JWT still carries actorUserId=${kBackDecoded?.actorUserId} after switch-back`, 'K5. JWT.actorUserId cleared')
961
+ assert(!kBackDecoded?.actorEmail, `JWT still carries actorEmail after switch-back`, 'K5. JWT.actorEmail cleared')
962
+ assert(!kBackDecoded?.actorBusinessId, `JWT still carries actorBusinessId after switch-back`, 'K5. JWT.actorBusinessId cleared')
963
+
964
+ // K5b. The switched-back token authenticates as a normal nonAdmin session.
965
+ const kBackSdk = new Session({ host })
966
+ kBackSdk.setAuthToken(kBackToken)
967
+ await async_test(
968
+ 'K5b. Switched-back token authenticates',
969
+ () => kBackSdk.test_authenticated(),
970
+ { shouldError: false, onResult: (r: any) => r === 'Authenticated!' }
971
+ )
972
+
973
+ // K6. Audit log for the switch-back: event=account_switch_back, userId=realActor (nonAdmin),
974
+ // proxySessionId=admin (the proxy identity that issued the request).
975
+ await wait(undefined, 500)
976
+ await async_test(
977
+ 'K6. user_logs has account_switch_back event with realActor as userId + proxySessionId',
978
+ () => sdk.api.user_logs.getSome({ filter: { resourceId: nonAdminId, resource: 'users', action: 'update' } } as any),
979
+ { shouldError: false, onResult: (logs: any[]) => (logs ?? []).some((l: any) => {
980
+ const info = l?.info ?? {}
981
+ return l?.userId === nonAdminId
982
+ && info.event === 'account_switch_back'
983
+ && info.sourceUserId === nonAdminId
984
+ && info.proxySessionId === adminId
985
+ && info.targetUserId === nonAdminId
986
+ })}
987
+ )
988
+
989
+ // K7. From the now-clean nonAdmin session (kBackSdk, minted by the K5 switch-back),
990
+ // switching back INTO admin works normally — grant unchanged, chain restarted with no
991
+ // leftover actor* state. Use kBackSdk because sdkNonAdmin's original token was invalidated
992
+ // by the K1 switch and we haven't re-authed it yet.
993
+ await async_test(
994
+ 'K7. Clean nonAdmin session can mint a fresh switch into admin (chain restarted)',
995
+ () => kBackSdk.api.users.switch_account({ targetUserId: adminId }),
996
+ { shouldError: false, onResult: (r: any) => typeof r.authToken === 'string' && r.authToken.length > 0 }
997
+ )
998
+ // Re-auth nonAdmin: its original token died in K1, and kBackSdk's token died in K7.
999
+ await sdkNonAdmin.authenticate(NON_ADMIN_EMAIL, NON_ADMIN_PASSWORD)
1000
+
1001
+ // ============================================================
1002
+ // K8. Chained switch A→B→C preserves the original actor (not the proxy)
1003
+ // ============================================================
1004
+ // Spec: from a switched A-as-B session, switching to C must mint a JWT with
1005
+ // actorUserId=A — never actorUserId=B. The B hop is a UI affordance and must not become
1006
+ // the new "actor" on subsequent switches. Requires a third user C with a direct grant
1007
+ // from C to A; admin creates C and uses generate_auth_token to bootstrap a C-session
1008
+ // for accepting the grant.
1009
+ log_header("K8. Chained switching preserves original actor")
1010
+
1011
+ // Create user C in admin's org (org already has accountSwitchingEnabled: true from setup).
1012
+ const userCEmail = `switch-c-${RAND()}@tellescope.example`
1013
+ const userCRecord: any = await sdk.api.users.createOne({
1014
+ email: userCEmail,
1015
+ fname: 'Chained',
1016
+ lname: 'Target',
1017
+ notificationEmailsDisabled: true,
1018
+ verifiedEmail: true,
1019
+ } as any)
1020
+ const userCId = userCRecord.id
1021
+
1022
+ // Mint a session token for C via admin's generate_auth_token — no need to set a password.
1023
+ const { authToken: userCToken } = await sdk.api.users.generate_auth_token({ id: userCId })
1024
+ const sdkC = new Session({ host })
1025
+ sdkC.setAuthToken(userCToken)
1026
+
1027
+ // A (nonAdmin) requests access to C; C accepts (validator requires session.id === ownerId).
1028
+ await sdkNonAdmin.api.users.request_linked_account_access({ targetEmail: userCEmail })
1029
+ await wait(undefined, 250)
1030
+ const userCState = await get_user(sdkC, userCId)
1031
+ const pendingForC = (userCState.linkedAccountAccess ?? []).find((e: any) => e.userId === nonAdminId)
1032
+ assert(!!pendingForC, 'K8 setup: pending entry should exist on C from nonAdmin', 'K8 setup: pending on C')
1033
+ await set_linkedAccountAccess(sdkC, userCId, [{ ...pendingForC, status: 'accepted' }])
1034
+ await wait(undefined, 250)
1035
+
1036
+ // Establish the A-as-B switched session. The admin→nonAdmin accepted grant from K1 is
1037
+ // still in place (K7's switch consumed a slot but didn't remove the grant).
1038
+ const aAsBResp: any = await sdkNonAdmin.api.users.switch_account({ targetUserId: adminId })
1039
+ const aAsBSdk = new Session({ host })
1040
+ aAsBSdk.setAuthToken(aAsBResp.authToken)
1041
+ aAsBSdk.setUserInfo(aAsBResp.user)
1042
+ const aAsBDecoded = decode_jwt(aAsBResp.authToken)
1043
+ assert(
1044
+ aAsBDecoded?.id === adminId && aAsBDecoded?.actorUserId === nonAdminId,
1045
+ `K8 setup: A-as-B token should carry id=admin, actorUserId=nonAdmin (got id=${aAsBDecoded?.id}, actorUserId=${aAsBDecoded?.actorUserId})`,
1046
+ 'K8 setup: A-as-B JWT confirmed',
1047
+ )
1048
+
1049
+ // Chained switch: from the A-as-B session, switch to C. realActor=nonAdmin (the original
1050
+ // actor) so the grant check looks up C.linkedAccountAccess for nonAdmin — finds the
1051
+ // accepted entry — and the new JWT must carry actorUserId=nonAdmin, NOT admin.
1052
+ let chainedToken = ''
1053
+ await async_test(
1054
+ 'K8. Chained switch A→B→C succeeds when C granted access to the actor',
1055
+ () => aAsBSdk.api.users.switch_account({ targetUserId: userCId }),
1056
+ { shouldError: false, onResult: (r: any) => {
1057
+ chainedToken = r.authToken
1058
+ return typeof r.authToken === 'string' && r.authToken.length > 0 && r.user?.id === userCId
1059
+ }}
1060
+ )
1061
+ const chainedDecoded = decode_jwt(chainedToken)
1062
+ assert(chainedDecoded?.id === userCId, `chained JWT.id ${chainedDecoded?.id} != ${userCId}`, 'K8. chained JWT.id == C')
1063
+ assert(
1064
+ chainedDecoded?.actorUserId === nonAdminId,
1065
+ `chained JWT.actorUserId is ${chainedDecoded?.actorUserId} — must remain nonAdmin (the original actor), NOT admin (the proxy hop)`,
1066
+ 'K8. chained JWT.actorUserId == A (original actor preserved, NOT B)',
1067
+ )
1068
+ assert(chainedDecoded?.actorEmail === nonAdminEmail, `chained JWT.actorEmail ${chainedDecoded?.actorEmail} != ${nonAdminEmail}`, 'K8. chained JWT.actorEmail preserved')
1069
+ assert(chainedDecoded?.actorBusinessId === nonAdminBusinessId, `chained JWT.actorBusinessId mismatch`, 'K8. chained JWT.actorBusinessId preserved')
1070
+
1071
+ // Audit log: the chained switch's user_log must attribute to the original actor (nonAdmin),
1072
+ // and record proxySessionId=adminId so the B-hop is reconstructable.
1073
+ await wait(undefined, 500)
1074
+ await async_test(
1075
+ 'K8. Chained switch audit log attributes to actor with proxySessionId=B',
1076
+ () => sdk.api.user_logs.getSome({ filter: { resourceId: userCId, resource: 'users', action: 'update' } } as any),
1077
+ { shouldError: false, onResult: (logs: any[]) => (logs ?? []).some((l: any) => {
1078
+ const info = l?.info ?? {}
1079
+ return l?.userId === nonAdminId
1080
+ && info.event === 'account_switch'
1081
+ && info.sourceUserId === nonAdminId
1082
+ && info.proxySessionId === adminId
1083
+ && info.targetUserId === userCId
1084
+ })}
1085
+ )
1086
+
1087
+ // K8b. From the chained C-session, get_linked_accounts must reflect the ACTOR's
1088
+ // perspective: actor's own account first (switch-back), plus all accounts that have
1089
+ // granted access to the actor (B/admin). The current proxy identity (C) must NOT appear
1090
+ // — caller is already in that session.
1091
+ const chainedSdk = new Session({ host })
1092
+ chainedSdk.setAuthToken(chainedToken)
1093
+ await async_test(
1094
+ 'K8b. get_linked_accounts from chained C-session reflects actor + actor-grants, excludes current proxy',
1095
+ () => chainedSdk.api.users.get_linked_accounts(),
1096
+ { shouldError: false, onResult: (r: any) => {
1097
+ const accounts = (r?.linkedAccounts ?? []) as any[]
1098
+ if (accounts.length === 0) return false
1099
+ // Switch-back entry (actor's own account) is first.
1100
+ if (accounts[0].id !== nonAdminId) return false
1101
+ // Admin appears because admin has nonAdmin: accepted in linkedAccountAccess.
1102
+ const hasAdmin = accounts.some((a: any) => a.id === adminId)
1103
+ // The current proxy identity (userC) must NOT appear — it's not switchable from itself.
1104
+ const hasSelfProxy = accounts.some((a: any) => a.id === userCId)
1105
+ return hasAdmin && !hasSelfProxy
1106
+ }}
1107
+ )
1108
+
1109
+ // Cleanup K8: delete C (also clears the linkedAccountAccess that the chained switch
1110
+ // consumed), and re-auth nonAdmin since the K8 setup switch invalidated its token.
1111
+ try { await sdk.api.users.deleteOne(userCId) } catch { /* ignore */ }
1112
+ await sdkNonAdmin.authenticate(NON_ADMIN_EMAIL, NON_ADMIN_PASSWORD)
1113
+
1114
+ // ============================================================
1115
+ // L. linkedAccountAccess read-side redaction (owner-only metadata)
1116
+ // ============================================================
1117
+ // The grant list reveals who's been requesting access to whom. Only the owner needs to
1118
+ // see it (to act on pending requests / inspect accepted grants). Cross-user reads,
1119
+ // switched-session reads, and the switch_account response.user must all redact the field.
1120
+ log_header("L. linkedAccountAccess read-side redaction")
1121
+
1122
+ // Seed an accepted grant so admin's record has a non-empty linkedAccountAccess for the
1123
+ // redaction assertions to be meaningful (a missing field is indistinguishable from a
1124
+ // redacted-empty field otherwise).
1125
+ await clear_linkedAccountAccess(sdk, adminId)
1126
+ await wait(undefined, 1500)
1127
+ await sdkNonAdmin.api.users.request_linked_account_access({ targetEmail: adminEmail })
1128
+ await wait(undefined, 250)
1129
+ const lSeedState = await get_user(sdk, adminId)
1130
+ const lPending = (lSeedState.linkedAccountAccess ?? []).find((e: any) => e.userId === nonAdminId)
1131
+ await set_linkedAccountAccess(sdk, adminId, [{ ...lPending, status: 'accepted' }])
1132
+ await wait(undefined, 250)
1133
+
1134
+ // L1. Owner reading own record from a non-switched session: field IS visible.
1135
+ await async_test(
1136
+ 'L1. Owner reading own record sees linkedAccountAccess',
1137
+ () => get_user(sdk, adminId),
1138
+ { shouldError: false, onResult: (u: any) => Array.isArray(u?.linkedAccountAccess) && u.linkedAccountAccess.length > 0 }
1139
+ )
1140
+
1141
+ // L2. Cross-user read (non-admin reads admin): field is redacted.
1142
+ await async_test(
1143
+ 'L2. Cross-user read (nonAdmin reads admin) redacts linkedAccountAccess',
1144
+ () => get_user(sdkNonAdmin, adminId),
1145
+ { shouldError: false, onResult: (u: any) => u?.linkedAccountAccess === undefined }
1146
+ )
1147
+
1148
+ // L3. Switched session reading the proxy's own record: still redacted (session.actorUserId
1149
+ // is set, so callerIsRealOwner is false even though value.id === session.id).
1150
+ const lSwitchResp: any = await sdkNonAdmin.api.users.switch_account({ targetUserId: adminId })
1151
+ const lSwitchedSdk = new Session({ host })
1152
+ lSwitchedSdk.setAuthToken(lSwitchResp.authToken)
1153
+ await async_test(
1154
+ 'L3. Switched session reading proxy record (admin) redacts linkedAccountAccess',
1155
+ () => get_user(lSwitchedSdk, adminId),
1156
+ { shouldError: false, onResult: (u: any) => u?.linkedAccountAccess === undefined }
1157
+ )
1158
+
1159
+ // L4. switch_account response.user does NOT include linkedAccountAccess (the response
1160
+ // bypasses applyRedactions, so the handler strips the field explicitly).
1161
+ assert(
1162
+ lSwitchResp?.user && lSwitchResp.user.id === adminId && lSwitchResp.user.linkedAccountAccess === undefined,
1163
+ `switch_account response.user.linkedAccountAccess should be undefined; got ${JSON.stringify(lSwitchResp?.user?.linkedAccountAccess)}`,
1164
+ 'L4. switch_account response.user has linkedAccountAccess redacted',
1165
+ )
1166
+
1167
+ // L5. After switching back to actor's own (non-switched) session, the owner CAN see their
1168
+ // own linkedAccountAccess again — the redaction only fires for cross-user / switched reads.
1169
+ // nonAdmin has no linkedAccountAccess in this fixture; verify the field is present and an
1170
+ // array (even if empty) rather than redacted-to-undefined.
1171
+ const lBackResp: any = await lSwitchedSdk.api.users.switch_account({ targetUserId: nonAdminId })
1172
+ const lBackSdk = new Session({ host })
1173
+ lBackSdk.setAuthToken(lBackResp.authToken)
1174
+ await async_test(
1175
+ 'L5. Owner-in-real-session can read their own linkedAccountAccess after switch-back',
1176
+ () => get_user(lBackSdk, nonAdminId),
1177
+ { shouldError: false, onResult: (u: any) => Array.isArray(u?.linkedAccountAccess) }
1178
+ )
1179
+
1180
+ // L cleanup: re-auth nonAdmin (lBackSdk's token wasn't invalidated, but we want a clean
1181
+ // sdkNonAdmin for the remaining sections).
1182
+ await sdkNonAdmin.authenticate(NON_ADMIN_EMAIL, NON_ADMIN_PASSWORD)
1183
+
1184
+ // ============================================================
1185
+ // M. Enduser sessions are rejected on user-only endpoints
1186
+ // ============================================================
1187
+ // The three new endpoints are registered as customActions on the `users` model with no
1188
+ // `allowEnduser` and no `enduserAction` declared. With only the user-type auth path active,
1189
+ // is_logged_in's type check (authentication.ts:587 — `if (userInfo.type !== type) return false`)
1190
+ // rejects the enduser JWT and checkAccess returns 401 "Unauthenticated" before businessOnly
1191
+ // even runs. Tests here are negative assertions guarding against future drift — e.g. if
1192
+ // someone adds `allowEnduser` to a custom action by accident.
1193
+ log_header("M. Enduser sessions rejected on user endpoints")
1194
+
1195
+ // Create an enduser inline and authenticate it via admin's generate_auth_token bypass.
1196
+ const enduserEmail = `switch-enduser-${RAND()}@tellescope.example`
1197
+ const enduserRec: any = await sdk.api.endusers.createOne({
1198
+ email: enduserEmail,
1199
+ fname: 'Switch',
1200
+ lname: 'Enduser',
1201
+ } as any)
1202
+ const { authToken: enduserAuthToken } = await sdk.api.endusers.generate_auth_token({ id: enduserRec.id })
1203
+
1204
+ // Use a plain Session with the enduser token so we can hit the user-only routes. The
1205
+ // EnduserSession's .api shape doesn't include these methods, but the underlying HTTP
1206
+ // routes do exist server-side — we want to confirm the server-side rejection fires
1207
+ // regardless of what client SDK is used.
1208
+ const sdkAsEnduser = new Session({ host })
1209
+ sdkAsEnduser.setAuthToken(enduserAuthToken)
1210
+
1211
+ // Reuse the 401 "Unauthenticated" matcher from C1 — that's what checkAccess actually
1212
+ // returns for an enduser JWT on a user-only endpoint. If a future change starts admitting
1213
+ // the enduser past checkAccess (e.g. adds allowEnduser) and the rejection moves to
1214
+ // businessOnly's 400, these assertions will fail loudly.
1215
+ const isEnduserRejection = is401Rejection
1216
+
1217
+ await async_test(
1218
+ 'M1. Enduser session is rejected on get_linked_accounts',
1219
+ () => sdkAsEnduser.api.users.get_linked_accounts(),
1220
+ { shouldError: true, onError: isEnduserRejection }
1221
+ )
1222
+ await async_test(
1223
+ 'M2. Enduser session is rejected on switch_account',
1224
+ () => sdkAsEnduser.api.users.switch_account({ targetUserId: adminId }),
1225
+ { shouldError: true, onError: isEnduserRejection }
1226
+ )
1227
+ await async_test(
1228
+ 'M3. Enduser session is rejected on request_linked_account_access',
1229
+ () => sdkAsEnduser.api.users.request_linked_account_access({ targetEmail: adminEmail }),
1230
+ { shouldError: true, onError: isEnduserRejection }
1231
+ )
1232
+
1233
+ // Cleanup the inline enduser.
1234
+ try { await sdk.api.endusers.deleteOne(enduserRec.id) } catch { /* ignore */ }
1235
+
1236
+ // ============================================================
1237
+ // C10. request_linked_account_access rate limit (placed last — exhausts admin's quota for 60s)
1238
+ // ============================================================
1239
+ log_header("C10. request_linked_account_access rate limit (placed last)")
1240
+ for (let i = 0; i < 30; i++) {
1241
+ try { await sdk.api.users.request_linked_account_access({ targetEmail: `rl-${RAND()}@tellescope.example` }) } catch {}
1242
+ }
1243
+ await async_test(
1244
+ 'C10. 31st request inside one minute is rate-limited (429)',
1245
+ () => sdk.api.users.request_linked_account_access({ targetEmail: `rl-${RAND()}@tellescope.example` }),
1246
+ { shouldError: true, onError: (e: any) => e.statusCode === 429 || (e.message || '').toLowerCase().includes('rate') }
1247
+ )
1248
+
1249
+ // ============================================================
1250
+ // J. Cleanup
1251
+ // ============================================================
1252
+ log_header("J. Cleanup")
1253
+ await clear_linkedAccountAccess(sdk, adminId)
1254
+ await clear_linkedAccountAccess(sdkNonAdmin, nonAdminId)
1255
+ await cleanup_marker_tags(sdk, adminId)
1256
+ await cleanup_marker_tags(sdkNonAdmin, nonAdminId)
1257
+ // Restore admin's fname (G5 mutated it server-side; downstream tests compare userInfo.fname to the server value).
1258
+ if (originalAdminFname) {
1259
+ try { await sdk.api.users.updateOne(adminId, { fname: originalAdminFname } as any) } catch {}
1260
+ }
1261
+ }
1262
+
1263
+ // Allow running this test file independently
1264
+ if (require.main === module) {
1265
+ console.log(`Using API URL: ${host}`)
1266
+ const sdk = new Session({ host })
1267
+ const sdkNonAdmin = new Session({ host })
1268
+
1269
+ const runTests = async () => {
1270
+ await setup_tests(sdk, sdkNonAdmin)
1271
+ await account_switcher_tests({ sdk, sdkNonAdmin })
1272
+ }
1273
+
1274
+ runTests()
1275
+ .then(() => {
1276
+ console.log("Account switcher test suite completed successfully")
1277
+ process.exit(0)
1278
+ })
1279
+ .catch((error) => {
1280
+ console.error("Account switcher test suite failed:", error)
1281
+ process.exit(1)
1282
+ })
1283
+ }