@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.
- package/lib/cjs/sdk.d.ts +9 -0
- package/lib/cjs/sdk.d.ts.map +1 -1
- package/lib/cjs/sdk.js +3 -0
- package/lib/cjs/sdk.js.map +1 -1
- package/lib/cjs/tests/api_tests/account_switcher.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/account_switcher.test.js +1700 -306
- package/lib/cjs/tests/api_tests/account_switcher.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/enduser_login.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/enduser_login.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/enduser_login.test.js +315 -0
- package/lib/cjs/tests/api_tests/enduser_login.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.js +370 -0
- package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/set_fields_order_templates.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/set_fields_order_templates.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/set_fields_order_templates.test.js +373 -0
- package/lib/cjs/tests/api_tests/set_fields_order_templates.test.js.map +1 -0
- package/lib/cjs/tests/setup.d.ts.map +1 -1
- package/lib/cjs/tests/setup.js +47 -32
- package/lib/cjs/tests/setup.js.map +1 -1
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +179 -158
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/esm/sdk.d.ts +9 -0
- package/lib/esm/sdk.d.ts.map +1 -1
- package/lib/esm/sdk.js +3 -0
- package/lib/esm/sdk.js.map +1 -1
- package/lib/esm/tests/api_tests/account_switcher.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/account_switcher.test.js +1702 -305
- package/lib/esm/tests/api_tests/account_switcher.test.js.map +1 -1
- package/lib/esm/tests/api_tests/enduser_login.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/enduser_login.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/enduser_login.test.js +308 -0
- package/lib/esm/tests/api_tests/enduser_login.test.js.map +1 -0
- package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.js +268 -0
- package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.js.map +1 -0
- package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.js +366 -0
- package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.js.map +1 -0
- package/lib/esm/tests/api_tests/set_fields_order_templates.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/set_fields_order_templates.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/set_fields_order_templates.test.js +369 -0
- package/lib/esm/tests/api_tests/set_fields_order_templates.test.js.map +1 -0
- package/lib/esm/tests/setup.d.ts.map +1 -1
- package/lib/esm/tests/setup.js +47 -32
- package/lib/esm/tests/setup.js.map +1 -1
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +179 -158
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/sdk.ts +12 -0
- package/src/tests/api_tests/account_switcher.test.ts +1283 -0
- package/src/tests/api_tests/enduser_login.test.ts +215 -0
- package/src/tests/api_tests/push_forms_to_portal_group_completion.test.ts +198 -0
- package/src/tests/api_tests/set_fields_order_templates.test.ts +258 -0
- package/src/tests/setup.ts +8 -1
- package/src/tests/tests.ts +18 -5
- 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
|
+
}
|