@oxyhq/core 20.1.0 → 21.0.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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/sessionColdBoot.js +107 -8
- package/dist/cjs/i18n/locales/en-US.json +19 -2
- package/dist/cjs/i18n/locales/es-ES.json +19 -2
- package/dist/cjs/i18n/locales/locales/en-US.json +19 -2
- package/dist/cjs/i18n/locales/locales/es-ES.json +19 -2
- package/dist/cjs/index.js +50 -16
- package/dist/cjs/mixins/OxyServices.auth.js +27 -3
- package/dist/cjs/session/SessionClient.js +361 -1
- package/dist/cjs/session/accountDialogController.js +121 -147
- package/dist/cjs/session/accountSwitchTargets.js +75 -0
- package/dist/cjs/session/deviceDirectory.js +143 -0
- package/dist/cjs/session/deviceSwitcherRows.js +76 -0
- package/dist/cjs/session/projectSessionState.js +8 -1
- package/dist/cjs/session/sharedDeviceCredential.js +247 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +107 -8
- package/dist/esm/i18n/locales/en-US.json +19 -2
- package/dist/esm/i18n/locales/es-ES.json +19 -2
- package/dist/esm/i18n/locales/locales/en-US.json +19 -2
- package/dist/esm/i18n/locales/locales/es-ES.json +19 -2
- package/dist/esm/index.js +32 -10
- package/dist/esm/mixins/OxyServices.auth.js +27 -3
- package/dist/esm/session/SessionClient.js +362 -2
- package/dist/esm/session/accountDialogController.js +121 -147
- package/dist/esm/session/accountSwitchTargets.js +71 -0
- package/dist/esm/session/deviceDirectory.js +135 -0
- package/dist/esm/session/deviceSwitcherRows.js +72 -0
- package/dist/esm/session/projectSessionState.js +8 -2
- package/dist/esm/session/sharedDeviceCredential.js +239 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/boot/sessionColdBoot.d.ts +24 -4
- package/dist/types/index.d.ts +8 -3
- package/dist/types/mixins/OxyServices.auth.d.ts +75 -3
- package/dist/types/models/session.d.ts +11 -0
- package/dist/types/session/SessionClient.d.ts +202 -1
- package/dist/types/session/accountDialogController.d.ts +76 -64
- package/dist/types/session/accountSwitchTargets.d.ts +64 -0
- package/dist/types/session/deviceDirectory.d.ts +182 -0
- package/dist/types/session/deviceSwitcherRows.d.ts +92 -0
- package/dist/types/session/projectSessionState.d.ts +29 -0
- package/dist/types/session/sharedDeviceCredential.d.ts +202 -0
- package/package.json +3 -3
- package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +325 -0
- package/src/boot/sessionColdBoot.ts +133 -9
- package/src/i18n/locales/en-US.json +19 -2
- package/src/i18n/locales/es-ES.json +19 -2
- package/src/index.ts +75 -18
- package/src/mixins/OxyServices.auth.ts +67 -5
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +54 -1
- package/src/models/session.ts +11 -0
- package/src/session/SessionClient.ts +386 -1
- package/src/session/__tests__/SessionClient.directory.test.ts +688 -0
- package/src/session/__tests__/accountDialogController.test.ts +411 -278
- package/src/session/__tests__/accountSwitchTargets.test.ts +132 -0
- package/src/session/__tests__/deviceDirectory.test.ts +422 -0
- package/src/session/__tests__/deviceSwitcherRows.test.ts +223 -0
- package/src/session/__tests__/projectSessionState.test.ts +17 -0
- package/src/session/__tests__/sharedDeviceCredential.test.ts +300 -0
- package/src/session/accountDialogController.ts +141 -179
- package/src/session/accountSwitchTargets.ts +87 -0
- package/src/session/deviceDirectory.ts +269 -0
- package/src/session/deviceSwitcherRows.ts +145 -0
- package/src/session/projectSessionState.ts +9 -3
- package/src/session/sharedDeviceCredential.ts +349 -0
- package/dist/cjs/session/accountProjection.js +0 -213
- package/dist/esm/session/accountProjection.js +0 -207
- package/dist/types/session/accountProjection.d.ts +0 -198
- package/src/session/__tests__/accountProjection.test.ts +0 -447
- package/src/session/accountProjection.ts +0 -354
|
@@ -20,15 +20,58 @@ class TestSessionClient extends SessionClient {
|
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* A valid, empty directory for the host below.
|
|
25
|
+
*
|
|
26
|
+
* `refresh()` reads `GET /session/device/directory` now, so the shared host has
|
|
27
|
+
* to answer it with something the contract accepts — otherwise every test in
|
|
28
|
+
* this file logs "discarded invalid device directory" and the ones asserting on
|
|
29
|
+
* the logger fail for a reason that has nothing to do with their subject.
|
|
30
|
+
*
|
|
31
|
+
* Its `revision` sits deliberately ahead of every state revision these tests
|
|
32
|
+
* use, so the directory-settle re-read (which has its own suite) never fires
|
|
33
|
+
* here and each test's request count stays about the thing it is testing.
|
|
34
|
+
*/
|
|
35
|
+
const EMPTY_DIRECTORY = {
|
|
36
|
+
deviceId: 'device-1',
|
|
37
|
+
revision: 9_000,
|
|
38
|
+
activeContextId: null,
|
|
39
|
+
principals: [],
|
|
40
|
+
updatedAt: 1_720_000_000_000,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
interface RecordingHost {
|
|
44
|
+
host: SessionClientHost;
|
|
45
|
+
/** Every path the client has requested, in order. */
|
|
46
|
+
urls: string[];
|
|
47
|
+
/** Make the next and every subsequent directory read reject with `error`. */
|
|
48
|
+
failDirectory: (error: unknown) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function host(): RecordingHost {
|
|
52
|
+
const urls: string[] = [];
|
|
53
|
+
let directoryError: unknown = null;
|
|
24
54
|
return {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
55
|
+
urls,
|
|
56
|
+
failDirectory: (error: unknown) => {
|
|
57
|
+
directoryError = error;
|
|
58
|
+
},
|
|
59
|
+
host: {
|
|
60
|
+
makeRequest: jest.fn(async (_method: string, url: string) => {
|
|
61
|
+
urls.push(url);
|
|
62
|
+
if (url === '/session/device/directory') {
|
|
63
|
+
if (directoryError !== null) throw directoryError;
|
|
64
|
+
return EMPTY_DIRECTORY;
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}),
|
|
68
|
+
getBaseURL: () => 'http://test.invalid',
|
|
69
|
+
getAccessToken: () => 'token',
|
|
70
|
+
getDeviceCredential: () => null,
|
|
71
|
+
onTokensChanged: () => () => undefined,
|
|
72
|
+
setTokens: jest.fn(),
|
|
73
|
+
getCurrentAccountId: () => null,
|
|
74
|
+
},
|
|
32
75
|
};
|
|
33
76
|
}
|
|
34
77
|
|
|
@@ -79,7 +122,6 @@ interface OxyMock {
|
|
|
79
122
|
startCommonsSignIn: jest.Mock;
|
|
80
123
|
deliverCommonsSignIn: jest.Mock;
|
|
81
124
|
pollCommonsSignIn: jest.Mock;
|
|
82
|
-
deliverCommonsSignIn: jest.Mock;
|
|
83
125
|
denyCommonsSignIn: jest.Mock;
|
|
84
126
|
claimSessionByToken: jest.Mock;
|
|
85
127
|
signInWithSharedIdentity: jest.Mock;
|
|
@@ -107,7 +149,6 @@ function makeOxy(): OxyMock {
|
|
|
107
149
|
getFileDownloadUrl: jest.fn((id: string) => `https://cdn/${id}`),
|
|
108
150
|
switchToAccount: jest.fn(),
|
|
109
151
|
startCommonsSignIn: jest.fn(),
|
|
110
|
-
deliverCommonsSignIn: jest.fn().mockResolvedValue({ delivered: false, targets: 0 }),
|
|
111
152
|
pollCommonsSignIn: jest.fn(),
|
|
112
153
|
// Default: no capable Commons installation is registered. A NORMAL outcome
|
|
113
154
|
// that resolves the primary route to QR — never an error.
|
|
@@ -133,6 +174,10 @@ interface Harness {
|
|
|
133
174
|
sc: TestSessionClient;
|
|
134
175
|
commitSession: jest.Mock;
|
|
135
176
|
onSignedIn: jest.Mock;
|
|
177
|
+
/** Every path the session client has requested, in order. */
|
|
178
|
+
urls: string[];
|
|
179
|
+
/** Make every directory read reject with `error`. */
|
|
180
|
+
failDirectory: (error: unknown) => void;
|
|
136
181
|
}
|
|
137
182
|
|
|
138
183
|
function makeHarness(
|
|
@@ -146,7 +191,8 @@ function makeHarness(
|
|
|
146
191
|
}> = {},
|
|
147
192
|
): Harness {
|
|
148
193
|
const oxy = makeOxy();
|
|
149
|
-
const
|
|
194
|
+
const recording = host();
|
|
195
|
+
const sc = new TestSessionClient(recording.host);
|
|
150
196
|
const commitSession = jest.fn().mockResolvedValue(undefined);
|
|
151
197
|
const onSignedIn = jest.fn();
|
|
152
198
|
const controller = createAccountDialogController({
|
|
@@ -162,18 +208,32 @@ function makeHarness(
|
|
|
162
208
|
openUrl: over.openUrl,
|
|
163
209
|
canOpenApp: over.canOpenApp,
|
|
164
210
|
});
|
|
165
|
-
return {
|
|
211
|
+
return {
|
|
212
|
+
controller,
|
|
213
|
+
oxy,
|
|
214
|
+
sc,
|
|
215
|
+
commitSession,
|
|
216
|
+
onSignedIn,
|
|
217
|
+
urls: recording.urls,
|
|
218
|
+
failDirectory: recording.failDirectory,
|
|
219
|
+
};
|
|
166
220
|
}
|
|
167
221
|
|
|
222
|
+
/** How many times the device directory has been read. */
|
|
223
|
+
const directoryReads = (urls: string[]): number =>
|
|
224
|
+
urls.filter((url) => url === '/session/device/directory').length;
|
|
225
|
+
|
|
168
226
|
describe('AccountDialogController — initial + views', () => {
|
|
169
227
|
it('starts on the accounts view with an empty list and idle sign-in', () => {
|
|
170
228
|
const { controller } = makeHarness();
|
|
171
229
|
const snap = controller.getSnapshot();
|
|
172
230
|
expect(snap.view).toBe('accounts');
|
|
173
|
-
expect(snap.
|
|
174
|
-
expect(snap.
|
|
231
|
+
expect(snap.directory).toBeNull();
|
|
232
|
+
expect(snap.activeContext).toBeNull();
|
|
175
233
|
expect(snap.loading).toBe(false);
|
|
176
|
-
expect(snap.
|
|
234
|
+
expect(snap.activatingContextId).toBeNull();
|
|
235
|
+
expect(snap.removingContextId).toBeNull();
|
|
236
|
+
expect(snap.removingPrincipalId).toBeNull();
|
|
177
237
|
expect(snap.signIn.phase).toBe('idle');
|
|
178
238
|
expect(snap.signIn.route).toBeNull();
|
|
179
239
|
expect(snap.signIn.routeFailed).toBe(false);
|
|
@@ -220,314 +280,110 @@ describe('AccountDialogController — initial + views', () => {
|
|
|
220
280
|
});
|
|
221
281
|
});
|
|
222
282
|
|
|
223
|
-
describe('AccountDialogController —
|
|
224
|
-
it('
|
|
225
|
-
const { controller, oxy, sc } = makeHarness();
|
|
226
|
-
|
|
227
|
-
oxy.
|
|
228
|
-
oxy.listAccounts.mockResolvedValue([graphNode('org1')]);
|
|
229
|
-
|
|
230
|
-
await controller.refresh();
|
|
231
|
-
|
|
232
|
-
const snap = controller.getSnapshot();
|
|
233
|
-
expect(oxy.getUsersByIds).toHaveBeenCalledWith(['a1', 'org1']);
|
|
234
|
-
expect(snap.accounts.map((r) => r.accountId)).toEqual(['a1', 'org1']);
|
|
235
|
-
expect(snap.activeAccountId).toBe('a1');
|
|
236
|
-
expect(snap.accounts[0].isCurrent).toBe(true);
|
|
237
|
-
expect(snap.accounts[1].onDevice).toBe(false);
|
|
238
|
-
expect(snap.loading).toBe(false);
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
it('start subscribes to SessionClient so a device-state change re-projects', async () => {
|
|
242
|
-
const { controller, oxy, sc } = makeHarness();
|
|
243
|
-
oxy.getUsersByIds.mockResolvedValue([user('a1')]);
|
|
244
|
-
controller.start();
|
|
245
|
-
await Promise.resolve();
|
|
246
|
-
await Promise.resolve();
|
|
247
|
-
|
|
248
|
-
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
249
|
-
// The subscription re-projects synchronously from the new device state.
|
|
250
|
-
expect(controller.getSnapshot().activeAccountId).toBe('a1');
|
|
251
|
-
controller.destroy();
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
it('keeps device rows and surfaces the error when listAccounts fails', async () => {
|
|
255
|
-
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
|
256
|
-
const { controller, oxy, sc } = makeHarness();
|
|
257
|
-
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
258
|
-
oxy.getUsersByIds.mockResolvedValue([user('a1')]);
|
|
259
|
-
oxy.listAccounts.mockRejectedValue(new Error('graph boom'));
|
|
260
|
-
|
|
261
|
-
await controller.refresh();
|
|
262
|
-
|
|
263
|
-
const snap = controller.getSnapshot();
|
|
264
|
-
expect(snap.error).toBe('graph boom');
|
|
265
|
-
expect(snap.accounts.map((r) => r.accountId)).toEqual(['a1']);
|
|
266
|
-
// A genuine (non-401) failure STILL warns.
|
|
267
|
-
expect(warnSpy).toHaveBeenCalledWith(
|
|
268
|
-
'[AccountDialogController] listAccounts failed',
|
|
269
|
-
{ component: 'AccountDialogController' },
|
|
270
|
-
expect.any(Error),
|
|
271
|
-
);
|
|
272
|
-
warnSpy.mockRestore();
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
it('treats a 401 from listAccounts as the signed-out edge — debug, no surfaced error, no warn', async () => {
|
|
276
|
-
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
|
277
|
-
const debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => undefined);
|
|
278
|
-
const { controller, oxy, sc } = makeHarness();
|
|
279
|
-
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
280
|
-
oxy.getUsersByIds.mockResolvedValue([user('a1')]);
|
|
281
|
-
// A stale/revoked bearer 401s: an EXPECTED signed-out outcome, not a failure.
|
|
282
|
-
oxy.listAccounts.mockRejectedValue(
|
|
283
|
-
Object.assign(new Error('Invalid or missing authorization header'), { status: 401 }),
|
|
284
|
-
);
|
|
285
|
-
|
|
286
|
-
await controller.refresh();
|
|
287
|
-
|
|
288
|
-
const snap = controller.getSnapshot();
|
|
289
|
-
// Never surface an error for a normal signed-out state; device rows still render.
|
|
290
|
-
expect(snap.error).toBeNull();
|
|
291
|
-
expect(snap.accounts.map((r) => r.accountId)).toEqual(['a1']);
|
|
292
|
-
expect(warnSpy).not.toHaveBeenCalled();
|
|
293
|
-
expect(debugSpy).toHaveBeenCalledWith(
|
|
294
|
-
'[AccountDialogController] listAccounts unauthorized (signed out)',
|
|
295
|
-
{ component: 'AccountDialogController' },
|
|
296
|
-
expect.objectContaining({ status: 401 }),
|
|
297
|
-
);
|
|
298
|
-
warnSpy.mockRestore();
|
|
299
|
-
debugSpy.mockRestore();
|
|
300
|
-
});
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
describe('AccountDialogController — auth-gated graph fetch (prod sign-out fix)', () => {
|
|
304
|
-
it('start() while signed out does NOT call the private listAccounts / getUsersByIds and does not error', async () => {
|
|
305
|
-
const { controller, oxy, sc } = makeHarness();
|
|
306
|
-
oxy.emitTokenChange(null); // cold boot: no bearer planted yet (no listeners registered pre-start)
|
|
283
|
+
describe('AccountDialogController — auth-gated directory read', () => {
|
|
284
|
+
it('start() while signed out makes no directory call and does not error', async () => {
|
|
285
|
+
const { controller, oxy, sc, urls } = makeHarness();
|
|
286
|
+
// Cold boot: no bearer planted yet (no listeners registered pre-start).
|
|
287
|
+
oxy.emitTokenChange(null);
|
|
307
288
|
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
308
289
|
|
|
309
290
|
controller.start();
|
|
310
291
|
await flush();
|
|
311
292
|
|
|
312
|
-
|
|
313
|
-
|
|
293
|
+
// The directory read is a PRIVATE call. Issuing it before the cold-boot
|
|
294
|
+
// restore plants the bearer 401s, which clears the token and signs the user
|
|
295
|
+
// out — the failure this gate exists for.
|
|
296
|
+
expect(urls).not.toContain('/session/device/directory');
|
|
314
297
|
const snap = controller.getSnapshot();
|
|
315
298
|
expect(snap.error).toBeNull();
|
|
316
299
|
expect(snap.loading).toBe(false);
|
|
317
300
|
controller.destroy();
|
|
318
301
|
});
|
|
319
302
|
|
|
320
|
-
it('
|
|
321
|
-
const { controller,
|
|
322
|
-
oxy.emitTokenChange(null);
|
|
323
|
-
|
|
324
|
-
await controller.refresh();
|
|
325
|
-
|
|
326
|
-
expect(oxy.listAccounts).not.toHaveBeenCalled();
|
|
327
|
-
const snap = controller.getSnapshot();
|
|
328
|
-
expect(snap.loading).toBe(false);
|
|
329
|
-
expect(snap.error).toBeNull();
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
it('start() while authenticated fetches the graph exactly once', async () => {
|
|
333
|
-
const { controller, oxy, sc } = makeHarness();
|
|
303
|
+
it('start() while authenticated reads the directory exactly once', async () => {
|
|
304
|
+
const { controller, sc, urls } = makeHarness();
|
|
334
305
|
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
335
|
-
oxy.listAccounts.mockResolvedValue([graphNode('org1')]);
|
|
336
|
-
oxy.getUsersByIds.mockResolvedValue([user('a1'), user('org1')]);
|
|
337
306
|
|
|
338
307
|
controller.start();
|
|
339
308
|
await flush();
|
|
340
309
|
|
|
341
|
-
expect(
|
|
342
|
-
expect(controller.getSnapshot().accounts.map((r) => r.accountId)).toEqual(['a1', 'org1']);
|
|
310
|
+
expect(directoryReads(urls)).toBe(1);
|
|
343
311
|
controller.destroy();
|
|
344
312
|
});
|
|
345
313
|
|
|
346
|
-
it('
|
|
347
|
-
const { controller, oxy } = makeHarness();
|
|
314
|
+
it('reads the directory once when the bearer is planted after a signed-out start', async () => {
|
|
315
|
+
const { controller, oxy, urls } = makeHarness();
|
|
348
316
|
oxy.emitTokenChange(null);
|
|
349
317
|
controller.start();
|
|
350
318
|
await flush();
|
|
351
|
-
expect(
|
|
319
|
+
expect(urls).not.toContain('/session/device/directory');
|
|
352
320
|
|
|
353
|
-
// Cold-boot restore plants the token → onTokensChanged →
|
|
321
|
+
// Cold-boot restore plants the token → onTokensChanged → one read.
|
|
354
322
|
oxy.emitTokenChange('access-token');
|
|
355
323
|
await flush();
|
|
356
|
-
expect(
|
|
324
|
+
expect(directoryReads(urls)).toBe(1);
|
|
357
325
|
controller.destroy();
|
|
358
326
|
});
|
|
359
327
|
|
|
360
|
-
it('
|
|
328
|
+
it('never reconstructs the account graph — no listAccounts, no getUsersByIds', async () => {
|
|
361
329
|
const { controller, oxy, sc } = makeHarness();
|
|
362
330
|
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
363
|
-
|
|
364
|
-
oxy.getUsersByIds.mockResolvedValue([user('a1'), user('org1')]);
|
|
331
|
+
|
|
365
332
|
controller.start();
|
|
366
333
|
await flush();
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
oxy.listAccounts.mockClear();
|
|
370
|
-
oxy.emitTokenChange(null); // a 401 cleared the bearer
|
|
334
|
+
// A device change used to refetch profiles for any newly-seen account id.
|
|
335
|
+
sc.set(state([{ accountId: 'a1', sessionId: 's1' }, { accountId: 'a2', sessionId: 's2' }], 'a2', 2));
|
|
371
336
|
await flush();
|
|
372
337
|
|
|
338
|
+
// ADR 0002's whole point: the client holds ONE caller's account graph and
|
|
339
|
+
// cannot enumerate another principal's, so a switcher assembled from it is
|
|
340
|
+
// one person's answer presented as the device's. The directory carries the
|
|
341
|
+
// rows AND the display metadata, so neither private call is made at all.
|
|
373
342
|
expect(oxy.listAccounts).not.toHaveBeenCalled();
|
|
374
|
-
|
|
375
|
-
expect(controller.getSnapshot().accounts.map((r) => r.accountId)).toEqual(['a1']);
|
|
343
|
+
expect(oxy.getUsersByIds).not.toHaveBeenCalled();
|
|
376
344
|
controller.destroy();
|
|
377
345
|
});
|
|
378
346
|
|
|
379
|
-
it('does not loop when
|
|
380
|
-
const
|
|
347
|
+
it('does not loop when the directory read rejects — one call per refresh, no re-trigger on device changes', async () => {
|
|
348
|
+
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
|
349
|
+
const { controller, sc, urls, failDirectory } = makeHarness();
|
|
350
|
+
failDirectory(new Error('directory boom'));
|
|
381
351
|
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
382
|
-
oxy.getUsersByIds.mockResolvedValue([user('a1')]);
|
|
383
|
-
oxy.listAccounts.mockRejectedValue(new Error('graph boom'));
|
|
384
352
|
|
|
385
353
|
controller.start();
|
|
386
354
|
await flush();
|
|
387
|
-
expect(
|
|
388
|
-
expect(controller.getSnapshot().error).toBe('
|
|
355
|
+
expect(directoryReads(urls)).toBe(1);
|
|
356
|
+
expect(controller.getSnapshot().error).toBe('directory boom');
|
|
389
357
|
|
|
390
|
-
// A subsequent device
|
|
391
|
-
//
|
|
358
|
+
// A subsequent device push must not re-trigger it (the auth edge did not
|
|
359
|
+
// move → `reconcileAuth` is a no-op → no storm).
|
|
392
360
|
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1', 2));
|
|
393
361
|
await flush();
|
|
394
|
-
expect(
|
|
395
|
-
// Device row still rendered despite the graph failure.
|
|
396
|
-
expect(controller.getSnapshot().accounts.map((r) => r.accountId)).toEqual(['a1']);
|
|
362
|
+
expect(directoryReads(urls)).toBe(1);
|
|
397
363
|
controller.destroy();
|
|
398
|
-
|
|
399
|
-
});
|
|
400
|
-
|
|
401
|
-
describe('AccountDialogController — switchTo (uniform switch)', () => {
|
|
402
|
-
it('uses SessionClient.switchAccount for an account already on the device', async () => {
|
|
403
|
-
const { controller, oxy, sc } = makeHarness();
|
|
404
|
-
sc.set(state([{ accountId: 'a1', sessionId: 's1' }, { accountId: 'a2', sessionId: 's2' }], 'a1'));
|
|
405
|
-
const switchSpy = jest.spyOn(sc, 'switchAccount').mockResolvedValue(undefined);
|
|
406
|
-
oxy.getUsersByIds.mockResolvedValue([user('a1'), user('a2')]);
|
|
407
|
-
|
|
408
|
-
await controller.switchTo('a2');
|
|
409
|
-
|
|
410
|
-
expect(switchSpy).toHaveBeenCalledWith('a2');
|
|
411
|
-
expect(oxy.switchToAccount).not.toHaveBeenCalled();
|
|
412
|
-
expect(controller.getSnapshot().switchingAccountId).toBeNull();
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
it('mints via oxyServices.switchToAccount + commitSession on first entry into a graph account', async () => {
|
|
416
|
-
const { controller, oxy, sc, commitSession } = makeHarness();
|
|
417
|
-
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
418
|
-
const switchSpy = jest.spyOn(sc, 'switchAccount').mockResolvedValue(undefined);
|
|
419
|
-
oxy.switchToAccount.mockResolvedValue({
|
|
420
|
-
sessionId: 'sess-org',
|
|
421
|
-
deviceId: 'device-1',
|
|
422
|
-
expiresAt: '2030-01-01T00:00:00Z',
|
|
423
|
-
accessToken: 'access-org',
|
|
424
|
-
user: user('org1'),
|
|
425
|
-
});
|
|
426
|
-
oxy.getUsersByIds.mockResolvedValue([user('a1'), user('org1')]);
|
|
427
|
-
|
|
428
|
-
await controller.switchTo('org1');
|
|
429
|
-
|
|
430
|
-
expect(oxy.switchToAccount).toHaveBeenCalledWith('org1');
|
|
431
|
-
expect(switchSpy).not.toHaveBeenCalled();
|
|
432
|
-
expect(commitSession).toHaveBeenCalledTimes(1);
|
|
433
|
-
expect(commitSession.mock.calls[0][0]).toMatchObject({ sessionId: 'sess-org', accessToken: 'access-org' });
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
it('commits a graph switch via the IN-PLACE commitSwitchedSession — never commitSession', async () => {
|
|
437
|
-
// An account switch must use commitSwitchedSession (in-place) and NEVER
|
|
438
|
-
// commitSession when both funnels are wired.
|
|
439
|
-
const oxy = makeOxy();
|
|
440
|
-
const sc = new TestSessionClient(host());
|
|
441
|
-
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
442
|
-
jest.spyOn(sc, 'switchAccount').mockResolvedValue(undefined);
|
|
443
|
-
oxy.switchToAccount.mockResolvedValue({
|
|
444
|
-
sessionId: 'sess-org',
|
|
445
|
-
deviceId: 'device-1',
|
|
446
|
-
expiresAt: '2030-01-01T00:00:00Z',
|
|
447
|
-
accessToken: 'access-org',
|
|
448
|
-
user: user('org1'),
|
|
449
|
-
});
|
|
450
|
-
const commitSession = jest.fn().mockResolvedValue(undefined);
|
|
451
|
-
const commitSwitchedSession = jest.fn().mockResolvedValue(undefined);
|
|
452
|
-
const controller = new AccountDialogController({
|
|
453
|
-
oxyServices: oxy as unknown as OxyServices,
|
|
454
|
-
sessionClient: sc,
|
|
455
|
-
clientId: 'oxy_dk_test',
|
|
456
|
-
commitSession,
|
|
457
|
-
commitSwitchedSession,
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
await controller.switchTo('org1');
|
|
461
|
-
|
|
462
|
-
expect(commitSwitchedSession).toHaveBeenCalledTimes(1);
|
|
463
|
-
expect(commitSwitchedSession.mock.calls[0][0]).toMatchObject({ sessionId: 'sess-org', accessToken: 'access-org' });
|
|
464
|
-
expect(commitSession).not.toHaveBeenCalled();
|
|
465
|
-
});
|
|
466
|
-
|
|
467
|
-
it('surfaces a failed switch as snapshot.error instead of silently no-op\'ing', async () => {
|
|
468
|
-
// PROBLEM 1: switching into an account the server refuses (e.g. a 403 for a
|
|
469
|
-
// personal-kind target) must tell the user why — the error is recorded on
|
|
470
|
-
// the snapshot, the switch does not throw, and switchingAccountId resets.
|
|
471
|
-
const { controller, oxy, sc } = makeHarness();
|
|
472
|
-
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
473
|
-
oxy.switchToAccount.mockRejectedValue(new Error('Cannot switch into a personal account'));
|
|
474
|
-
|
|
475
|
-
await expect(controller.switchTo('org1')).resolves.toBe(false);
|
|
476
|
-
|
|
477
|
-
const snap = controller.getSnapshot();
|
|
478
|
-
expect(snap.error).toBe('Cannot switch into a personal account');
|
|
479
|
-
expect(snap.switchingAccountId).toBeNull();
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
it('falls back to SessionClient.registerAndActivate when no commitSession is supplied', async () => {
|
|
483
|
-
const oxy = makeOxy();
|
|
484
|
-
const sc = new TestSessionClient(host());
|
|
485
|
-
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
486
|
-
const registerSpy = jest.spyOn(sc, 'registerAndActivate').mockResolvedValue(undefined);
|
|
487
|
-
oxy.switchToAccount.mockResolvedValue({
|
|
488
|
-
sessionId: 'sess-org',
|
|
489
|
-
deviceId: 'device-1',
|
|
490
|
-
expiresAt: '2030-01-01T00:00:00Z',
|
|
491
|
-
accessToken: 'access-org',
|
|
492
|
-
user: user('org1'),
|
|
493
|
-
});
|
|
494
|
-
const controller = new AccountDialogController({
|
|
495
|
-
oxyServices: oxy as unknown as OxyServices,
|
|
496
|
-
sessionClient: sc,
|
|
497
|
-
clientId: 'oxy_dk_test',
|
|
498
|
-
});
|
|
499
|
-
|
|
500
|
-
await controller.switchTo('org1');
|
|
501
|
-
expect(registerSpy).toHaveBeenCalledWith('org1');
|
|
364
|
+
warnSpy.mockRestore();
|
|
502
365
|
});
|
|
503
366
|
|
|
504
|
-
it('
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
(
|
|
367
|
+
it('treats a 401 from the directory read as the signed-out edge — debug, no surfaced error, no warn', async () => {
|
|
368
|
+
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
|
369
|
+
const debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => undefined);
|
|
370
|
+
const { controller, failDirectory } = makeHarness();
|
|
371
|
+
failDirectory(
|
|
372
|
+
Object.assign(new Error('Invalid or missing authorization header'), { status: 401 }),
|
|
510
373
|
);
|
|
511
374
|
|
|
512
|
-
|
|
513
|
-
expect(controller.getSnapshot().switchingAccountId).toBe('a2');
|
|
514
|
-
await controller.switchTo('a1'); // ignored — a switch is in flight
|
|
515
|
-
expect(sc.switchAccount).toHaveBeenCalledTimes(1);
|
|
516
|
-
|
|
517
|
-
release();
|
|
518
|
-
await first;
|
|
519
|
-
});
|
|
520
|
-
|
|
521
|
-
it('returns true when the switch succeeds even if the post-switch refresh fails', async () => {
|
|
522
|
-
const { controller, oxy, sc } = makeHarness();
|
|
523
|
-
sc.set(state([{ accountId: 'a1', sessionId: 's1' }, { accountId: 'a2', sessionId: 's2' }], 'a1'));
|
|
524
|
-
jest.spyOn(sc, 'switchAccount').mockResolvedValue(undefined);
|
|
525
|
-
oxy.listAccounts.mockRejectedValue(new Error('network down'));
|
|
526
|
-
|
|
527
|
-
await expect(controller.switchTo('a2')).resolves.toBe(true);
|
|
375
|
+
await controller.refresh();
|
|
528
376
|
|
|
529
|
-
|
|
530
|
-
expect(controller.getSnapshot().
|
|
377
|
+
// A signed-out device is a normal state, not a warning.
|
|
378
|
+
expect(controller.getSnapshot().error).toBeNull();
|
|
379
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
380
|
+
expect(debugSpy).toHaveBeenCalledWith(
|
|
381
|
+
'[AccountDialogController] directory unauthorized (signed out)',
|
|
382
|
+
{ component: 'AccountDialogController' },
|
|
383
|
+
expect.objectContaining({ status: 401 }),
|
|
384
|
+
);
|
|
385
|
+
warnSpy.mockRestore();
|
|
386
|
+
debugSpy.mockRestore();
|
|
531
387
|
});
|
|
532
388
|
});
|
|
533
389
|
|
|
@@ -871,7 +727,7 @@ describe('AccountDialogController — Commons availability (canOpenApp)', () =>
|
|
|
871
727
|
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
|
|
872
728
|
const controller = new AccountDialogController({
|
|
873
729
|
oxyServices: oxy as unknown as OxyServices,
|
|
874
|
-
sessionClient: new TestSessionClient(host()),
|
|
730
|
+
sessionClient: new TestSessionClient(host().host),
|
|
875
731
|
clientId: 'oxy_dk_test',
|
|
876
732
|
pollIntervalMs: 1000,
|
|
877
733
|
openUrl: opts.openUrl,
|
|
@@ -995,7 +851,7 @@ describe('AccountDialogController — /auth-session socket (instant QR wake)', (
|
|
|
995
851
|
const commitSession = jest.fn().mockResolvedValue(undefined);
|
|
996
852
|
const controller = new AccountDialogController({
|
|
997
853
|
oxyServices: oxy as unknown as OxyServices,
|
|
998
|
-
sessionClient: new TestSessionClient(host()),
|
|
854
|
+
sessionClient: new TestSessionClient(host().host),
|
|
999
855
|
clientId: 'oxy_dk_test',
|
|
1000
856
|
commitSession,
|
|
1001
857
|
socketFactory: factory as unknown as SocketIOFactory,
|
|
@@ -1114,7 +970,7 @@ function makeDeliveryHarness(opts: {
|
|
|
1114
970
|
if (opts.authenticated === false) oxy.emitTokenChange(null);
|
|
1115
971
|
const controller = new AccountDialogController({
|
|
1116
972
|
oxyServices: oxy as unknown as OxyServices,
|
|
1117
|
-
sessionClient: new TestSessionClient(host()),
|
|
973
|
+
sessionClient: new TestSessionClient(host().host),
|
|
1118
974
|
clientId: 'oxy_dk_test',
|
|
1119
975
|
// Every real consumer wires the provider's commit funnel; without it the
|
|
1120
976
|
// controller falls back to `SessionClient.registerAndActivate`, which opens
|
|
@@ -1408,7 +1264,7 @@ describe('AccountDialogController — automatic delivery selection (#691 phase 5
|
|
|
1408
1264
|
oxy.deliverCommonsSignIn.mockResolvedValue({ delivered: true, targets: 1 });
|
|
1409
1265
|
const controller = new AccountDialogController({
|
|
1410
1266
|
oxyServices: oxy as unknown as OxyServices,
|
|
1411
|
-
sessionClient: new TestSessionClient(host()),
|
|
1267
|
+
sessionClient: new TestSessionClient(host().host),
|
|
1412
1268
|
clientId: 'oxy_dk_test',
|
|
1413
1269
|
pollIntervalMs: 1000,
|
|
1414
1270
|
platform: 'desktop',
|
|
@@ -1656,7 +1512,7 @@ describe('AccountDialogController — cancellation converges (#691 phase 5)', ()
|
|
|
1656
1512
|
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
|
|
1657
1513
|
const controller = new AccountDialogController({
|
|
1658
1514
|
oxyServices: oxy as unknown as OxyServices,
|
|
1659
|
-
sessionClient: new TestSessionClient(host()),
|
|
1515
|
+
sessionClient: new TestSessionClient(host().host),
|
|
1660
1516
|
clientId: 'oxy_dk_test',
|
|
1661
1517
|
pollIntervalMs: 1000,
|
|
1662
1518
|
platform: 'desktop',
|
|
@@ -1772,7 +1628,7 @@ describe('AccountDialogController — cancellation converges (#691 phase 5)', ()
|
|
|
1772
1628
|
});
|
|
1773
1629
|
const controller = new AccountDialogController({
|
|
1774
1630
|
oxyServices: oxy as unknown as OxyServices,
|
|
1775
|
-
sessionClient: new TestSessionClient(host()),
|
|
1631
|
+
sessionClient: new TestSessionClient(host().host),
|
|
1776
1632
|
clientId: 'oxy_dk_test',
|
|
1777
1633
|
pollIntervalMs: 1000,
|
|
1778
1634
|
platform: 'desktop',
|
|
@@ -1813,6 +1669,283 @@ describe('AccountDialogController — lifecycle', () => {
|
|
|
1813
1669
|
});
|
|
1814
1670
|
});
|
|
1815
1671
|
|
|
1672
|
+
describe('AccountDialogController — the directory (ADR 0002)', () => {
|
|
1673
|
+
/** A two-principal device where `org` is reachable through BOTH people. */
|
|
1674
|
+
const sharedDirectory = (revision: number, activeContextId: string | null) => ({
|
|
1675
|
+
deviceId: 'device-1',
|
|
1676
|
+
revision,
|
|
1677
|
+
activeContextId,
|
|
1678
|
+
updatedAt: 1_720_000_000_000,
|
|
1679
|
+
principals: [
|
|
1680
|
+
{
|
|
1681
|
+
id: 'p-nate',
|
|
1682
|
+
userId: 'nate',
|
|
1683
|
+
authuser: 0,
|
|
1684
|
+
user: { id: 'nate', username: 'nate' },
|
|
1685
|
+
contexts: [
|
|
1686
|
+
{
|
|
1687
|
+
id: 'ctx-nate-org',
|
|
1688
|
+
accountId: 'org',
|
|
1689
|
+
kind: 'organization' as const,
|
|
1690
|
+
relationship: 'owner' as const,
|
|
1691
|
+
account: { id: 'org', username: 'oxy' },
|
|
1692
|
+
onDevice: true,
|
|
1693
|
+
available: true,
|
|
1694
|
+
active: activeContextId === 'ctx-nate-org',
|
|
1695
|
+
lastUsedAt: null,
|
|
1696
|
+
},
|
|
1697
|
+
],
|
|
1698
|
+
},
|
|
1699
|
+
{
|
|
1700
|
+
id: 'p-alice',
|
|
1701
|
+
userId: 'alice',
|
|
1702
|
+
authuser: 1,
|
|
1703
|
+
user: { id: 'alice', username: 'alice' },
|
|
1704
|
+
contexts: [
|
|
1705
|
+
{
|
|
1706
|
+
id: 'ctx-alice-org',
|
|
1707
|
+
accountId: 'org',
|
|
1708
|
+
kind: 'organization' as const,
|
|
1709
|
+
relationship: 'member' as const,
|
|
1710
|
+
account: { id: 'org', username: 'oxy' },
|
|
1711
|
+
onDevice: true,
|
|
1712
|
+
available: true,
|
|
1713
|
+
active: activeContextId === 'ctx-alice-org',
|
|
1714
|
+
lastUsedAt: null,
|
|
1715
|
+
},
|
|
1716
|
+
],
|
|
1717
|
+
},
|
|
1718
|
+
],
|
|
1719
|
+
});
|
|
1720
|
+
|
|
1721
|
+
function directoryHarness(activeContextId: string | null) {
|
|
1722
|
+
const oxy = makeOxy();
|
|
1723
|
+
const urls: string[] = [];
|
|
1724
|
+
const bodies: unknown[] = [];
|
|
1725
|
+
const sc = new TestSessionClient({
|
|
1726
|
+
makeRequest: jest.fn(async (_method: string, url: string, data?: unknown) => {
|
|
1727
|
+
urls.push(url);
|
|
1728
|
+
bodies.push(data);
|
|
1729
|
+
if (url === '/session/device/directory') return sharedDirectory(5, activeContextId);
|
|
1730
|
+
if (url === '/session/device/activate') {
|
|
1731
|
+
return { directory: sharedDirectory(6, 'ctx-alice-org'), activeToken: null };
|
|
1732
|
+
}
|
|
1733
|
+
if (url === '/session/device/signout') {
|
|
1734
|
+
// The context-aware removals answer with their OWN contract — the
|
|
1735
|
+
// directory AND the flat state, because a removal elects a
|
|
1736
|
+
// replacement active context and both halves move together.
|
|
1737
|
+
return {
|
|
1738
|
+
directory: sharedDirectory(7, null),
|
|
1739
|
+
state: state([{ accountId: 'org', sessionId: 's-org' }], 'org', 7),
|
|
1740
|
+
activeToken: null,
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
return undefined;
|
|
1744
|
+
}),
|
|
1745
|
+
getBaseURL: () => 'http://test.invalid',
|
|
1746
|
+
getAccessToken: () => 'token',
|
|
1747
|
+
getDeviceCredential: () => null,
|
|
1748
|
+
onTokensChanged: () => () => undefined,
|
|
1749
|
+
setTokens: jest.fn(),
|
|
1750
|
+
getCurrentAccountId: () => null,
|
|
1751
|
+
});
|
|
1752
|
+
const controller = createAccountDialogController({
|
|
1753
|
+
oxyServices: oxy as unknown as OxyServices,
|
|
1754
|
+
sessionClient: sc,
|
|
1755
|
+
clientId: 'oxy_dk_test',
|
|
1756
|
+
commitSession: jest.fn().mockResolvedValue(undefined),
|
|
1757
|
+
onSignedIn: jest.fn(),
|
|
1758
|
+
});
|
|
1759
|
+
return { controller, oxy, sc, urls, bodies };
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
it('publishes the directory and the active context on the snapshot', async () => {
|
|
1763
|
+
const { controller, oxy } = directoryHarness('ctx-alice-org');
|
|
1764
|
+
oxy.listAccounts.mockResolvedValue([]);
|
|
1765
|
+
oxy.getUsersByIds.mockResolvedValue([]);
|
|
1766
|
+
|
|
1767
|
+
await controller.refresh();
|
|
1768
|
+
|
|
1769
|
+
const snap = controller.getSnapshot();
|
|
1770
|
+
expect(snap.directory?.revision).toBe(5);
|
|
1771
|
+
// The flat `accounts` list cannot express this: it is keyed by account id,
|
|
1772
|
+
// so ONE of these two routes to `org` would be all a consumer ever saw.
|
|
1773
|
+
expect(snap.directory?.principals.map((p) => p.userId)).toEqual(['nate', 'alice']);
|
|
1774
|
+
expect(snap.activeContext?.contextId).toBe('ctx-alice-org');
|
|
1775
|
+
expect(snap.activeContext?.actor.userId).toBe('alice');
|
|
1776
|
+
expect(snap.activeContext?.subject.accountId).toBe('org');
|
|
1777
|
+
expect(snap.activeContext?.isDelegated).toBe(true);
|
|
1778
|
+
});
|
|
1779
|
+
|
|
1780
|
+
it('activateContext POSTs the contextId and reports the switch in flight', async () => {
|
|
1781
|
+
const { controller, oxy, urls, bodies } = directoryHarness('ctx-nate-org');
|
|
1782
|
+
oxy.listAccounts.mockResolvedValue([]);
|
|
1783
|
+
oxy.getUsersByIds.mockResolvedValue([]);
|
|
1784
|
+
await controller.refresh();
|
|
1785
|
+
|
|
1786
|
+
const inFlight: Array<string | null> = [];
|
|
1787
|
+
controller.subscribe((s) => inFlight.push(s.activatingContextId));
|
|
1788
|
+
|
|
1789
|
+
expect(await controller.activateContext('ctx-alice-org')).toBe(true);
|
|
1790
|
+
|
|
1791
|
+
expect(urls).toContain('/session/device/activate');
|
|
1792
|
+
expect(bodies[urls.indexOf('/session/device/activate')]).toEqual({ contextId: 'ctx-alice-org' });
|
|
1793
|
+
// Reported while running, cleared after — a row can show a spinner.
|
|
1794
|
+
expect(inFlight).toContain('ctx-alice-org');
|
|
1795
|
+
expect(controller.getSnapshot().activatingContextId).toBeNull();
|
|
1796
|
+
expect(controller.getSnapshot().activeContext?.actor.userId).toBe('alice');
|
|
1797
|
+
});
|
|
1798
|
+
|
|
1799
|
+
it('refuses a second activation while one is in flight', async () => {
|
|
1800
|
+
const { controller, oxy } = directoryHarness('ctx-nate-org');
|
|
1801
|
+
oxy.listAccounts.mockResolvedValue([]);
|
|
1802
|
+
oxy.getUsersByIds.mockResolvedValue([]);
|
|
1803
|
+
await controller.refresh();
|
|
1804
|
+
|
|
1805
|
+
const first = controller.activateContext('ctx-alice-org');
|
|
1806
|
+
expect(await controller.activateContext('ctx-nate-org')).toBe(false);
|
|
1807
|
+
expect(await first).toBe(true);
|
|
1808
|
+
});
|
|
1809
|
+
|
|
1810
|
+
it('surfaces an activation failure as a dialog error without wedging the flag', async () => {
|
|
1811
|
+
const { controller, oxy, sc } = directoryHarness('ctx-nate-org');
|
|
1812
|
+
oxy.listAccounts.mockResolvedValue([]);
|
|
1813
|
+
oxy.getUsersByIds.mockResolvedValue([]);
|
|
1814
|
+
await controller.refresh();
|
|
1815
|
+
// A stale context id is an ordinary outcome, not a bug: the server 404s and
|
|
1816
|
+
// heals the row away.
|
|
1817
|
+
jest.spyOn(sc, 'activateContext').mockRejectedValue(new Error('Context not on this device'));
|
|
1818
|
+
|
|
1819
|
+
expect(await controller.activateContext('ctx-gone')).toBe(false);
|
|
1820
|
+
|
|
1821
|
+
expect(controller.getSnapshot().error).toBe('Context not on this device');
|
|
1822
|
+
expect(controller.getSnapshot().activatingContextId).toBeNull();
|
|
1823
|
+
});
|
|
1824
|
+
|
|
1825
|
+
it('keeps the directory it already holds when a later read fails, and says so', async () => {
|
|
1826
|
+
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
|
1827
|
+
const { controller, failDirectory } = makeHarness();
|
|
1828
|
+
|
|
1829
|
+
await controller.refresh();
|
|
1830
|
+
expect(controller.getSnapshot().directory?.revision).toBe(9_000);
|
|
1831
|
+
|
|
1832
|
+
failDirectory(new Error('boom'));
|
|
1833
|
+
await controller.refresh();
|
|
1834
|
+
|
|
1835
|
+
// Degraded, not blank. A transient outage must not empty a switcher the
|
|
1836
|
+
// user is looking at — but it must not pretend either, so the error is on
|
|
1837
|
+
// the snapshot for the surface to render.
|
|
1838
|
+
const snap = controller.getSnapshot();
|
|
1839
|
+
expect(snap.directory?.revision).toBe(9_000);
|
|
1840
|
+
expect(snap.error).toBe('boom');
|
|
1841
|
+
expect(snap.loading).toBe(false);
|
|
1842
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
1843
|
+
'[AccountDialogController] directory refresh failed',
|
|
1844
|
+
{ component: 'AccountDialogController' },
|
|
1845
|
+
expect.any(Error),
|
|
1846
|
+
);
|
|
1847
|
+
warnSpy.mockRestore();
|
|
1848
|
+
});
|
|
1849
|
+
|
|
1850
|
+
it('spins only while there is nothing to show', async () => {
|
|
1851
|
+
const { controller } = makeHarness();
|
|
1852
|
+
const loading: boolean[] = [];
|
|
1853
|
+
controller.subscribe((s) => loading.push(s.loading));
|
|
1854
|
+
|
|
1855
|
+
// First read: nothing held, so the switcher has nothing to render.
|
|
1856
|
+
await controller.refresh();
|
|
1857
|
+
expect(loading).toContain(true);
|
|
1858
|
+
expect(controller.getSnapshot().loading).toBe(false);
|
|
1859
|
+
|
|
1860
|
+
// Second read, behind a directory already on screen: refreshed in place,
|
|
1861
|
+
// never blanked back to a spinner.
|
|
1862
|
+
loading.length = 0;
|
|
1863
|
+
await controller.refresh();
|
|
1864
|
+
expect(loading).not.toContain(true);
|
|
1865
|
+
});
|
|
1866
|
+
|
|
1867
|
+
it('removes ONE route to a shared account, naming the pair and not the account', async () => {
|
|
1868
|
+
const { controller, urls, bodies } = directoryHarness('ctx-nate-org');
|
|
1869
|
+
await controller.refresh();
|
|
1870
|
+
|
|
1871
|
+
const inFlight: Array<string | null> = [];
|
|
1872
|
+
controller.subscribe((s) => inFlight.push(s.removingContextId));
|
|
1873
|
+
|
|
1874
|
+
expect(await controller.signOutContext('ctx-alice-org')).toBe(true);
|
|
1875
|
+
|
|
1876
|
+
const index = urls.indexOf('/session/device/signout');
|
|
1877
|
+
expect(index).toBeGreaterThanOrEqual(0);
|
|
1878
|
+
// `{ contextId }`, never `{ accountId }`. Both people reach `org` here, and
|
|
1879
|
+
// an account id would revoke Nate's route as a side effect of Alice tidying
|
|
1880
|
+
// her own list.
|
|
1881
|
+
expect(bodies[index]).toEqual({ contextId: 'ctx-alice-org' });
|
|
1882
|
+
expect(inFlight).toContain('ctx-alice-org');
|
|
1883
|
+
expect(controller.getSnapshot().removingContextId).toBeNull();
|
|
1884
|
+
});
|
|
1885
|
+
|
|
1886
|
+
it('removes ONE PERSON through the principal id, not through their contexts', async () => {
|
|
1887
|
+
const { controller, urls, bodies } = directoryHarness('ctx-nate-org');
|
|
1888
|
+
await controller.refresh();
|
|
1889
|
+
|
|
1890
|
+
const inFlight: Array<string | null> = [];
|
|
1891
|
+
controller.subscribe((s) => inFlight.push(s.removingPrincipalId));
|
|
1892
|
+
|
|
1893
|
+
expect(await controller.signOutPrincipal('p-alice')).toBe(true);
|
|
1894
|
+
|
|
1895
|
+
const index = urls.indexOf('/session/device/signout');
|
|
1896
|
+
// `{ principalId }` — one call, not a loop over that person's contexts, and
|
|
1897
|
+
// emphatically not the context endpoint: pointing this at `{ contextId }`
|
|
1898
|
+
// would drop one pair and leave the person on the device.
|
|
1899
|
+
expect(bodies[index]).toEqual({ principalId: 'p-alice' });
|
|
1900
|
+
expect(urls.filter((u) => u === '/session/device/signout')).toHaveLength(1);
|
|
1901
|
+
expect(inFlight).toContain('p-alice');
|
|
1902
|
+
expect(controller.getSnapshot().removingPrincipalId).toBeNull();
|
|
1903
|
+
});
|
|
1904
|
+
|
|
1905
|
+
it('refuses a second removal while one is in flight, of either kind', async () => {
|
|
1906
|
+
const { controller, sc } = directoryHarness('ctx-nate-org');
|
|
1907
|
+
await controller.refresh();
|
|
1908
|
+
let release: () => void = () => undefined;
|
|
1909
|
+
jest.spyOn(sc, 'signOutContext').mockImplementation(
|
|
1910
|
+
() => new Promise<void>((resolve) => { release = resolve; }),
|
|
1911
|
+
);
|
|
1912
|
+
const principalSpy = jest.spyOn(sc, 'signOutPrincipal').mockResolvedValue(undefined);
|
|
1913
|
+
|
|
1914
|
+
const first = controller.signOutContext('ctx-alice-org');
|
|
1915
|
+
// The two removals share one gate: they mutate the same device and a
|
|
1916
|
+
// concurrent pair would race for which replacement context ends up active.
|
|
1917
|
+
expect(await controller.signOutPrincipal('p-alice')).toBe(false);
|
|
1918
|
+
expect(principalSpy).not.toHaveBeenCalled();
|
|
1919
|
+
|
|
1920
|
+
release();
|
|
1921
|
+
expect(await first).toBe(true);
|
|
1922
|
+
});
|
|
1923
|
+
|
|
1924
|
+
it('surfaces a failed removal without wedging the flag', async () => {
|
|
1925
|
+
const { controller, sc } = directoryHarness('ctx-nate-org');
|
|
1926
|
+
await controller.refresh();
|
|
1927
|
+
// A context id is not stable across a removal, so a stale one is ordinary:
|
|
1928
|
+
// the server refuses and the next read simply does not offer it.
|
|
1929
|
+
jest.spyOn(sc, 'signOutContext').mockRejectedValue(new Error('Context not on this device'));
|
|
1930
|
+
|
|
1931
|
+
expect(await controller.signOutContext('ctx-gone')).toBe(false);
|
|
1932
|
+
|
|
1933
|
+
expect(controller.getSnapshot().error).toBe('Context not on this device');
|
|
1934
|
+
expect(controller.getSnapshot().removingContextId).toBeNull();
|
|
1935
|
+
});
|
|
1936
|
+
|
|
1937
|
+
it('makes no directory call while signed out', async () => {
|
|
1938
|
+
const { controller, oxy, urls } = directoryHarness(null);
|
|
1939
|
+
oxy.getAccessToken.mockReturnValue(null);
|
|
1940
|
+
oxy.listAccounts.mockResolvedValue([]);
|
|
1941
|
+
|
|
1942
|
+
await controller.refresh();
|
|
1943
|
+
|
|
1944
|
+
expect(urls).not.toContain('/session/device/directory');
|
|
1945
|
+
expect(controller.getSnapshot().directory).toBeNull();
|
|
1946
|
+
});
|
|
1947
|
+
});
|
|
1948
|
+
|
|
1816
1949
|
it('createAccountDialogController returns an AccountDialogController instance', () => {
|
|
1817
1950
|
const { controller } = makeHarness();
|
|
1818
1951
|
expect(controller).toBeInstanceOf(AccountDialogController);
|