@oxyhq/core 7.0.0 → 7.1.1

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.
@@ -0,0 +1,592 @@
1
+ import type { DeviceSessionState } from '@oxyhq/contracts';
2
+ import type { OxyServices } from '../../OxyServices';
3
+ import type { User } from '../../models/interfaces';
4
+ import type { SessionLoginResponse, MinimalUserData } from '../../models/session';
5
+ import type { AccountNode } from '../../mixins/OxyServices.accounts';
6
+ import { SessionClient, type SessionClientHost } from '../SessionClient';
7
+ import {
8
+ AccountDialogController,
9
+ createAccountDialogController,
10
+ } from '../accountDialogController';
11
+
12
+ // A SessionClient whose applied state can be driven directly (applyState is
13
+ // protected on the base) — mirrors the existing TestClient pattern.
14
+ class TestSessionClient extends SessionClient {
15
+ set(state: DeviceSessionState): void {
16
+ this.applyState(state);
17
+ }
18
+ }
19
+
20
+ function host(): SessionClientHost {
21
+ return {
22
+ makeRequest: jest.fn(),
23
+ getBaseURL: () => 'http://test.invalid',
24
+ getAccessToken: () => 'token',
25
+ onTokensChanged: () => () => undefined,
26
+ setTokens: jest.fn(),
27
+ getCurrentAccountId: () => null,
28
+ };
29
+ }
30
+
31
+ function state(
32
+ accounts: Array<{ accountId: string; sessionId: string }>,
33
+ activeAccountId: string | null,
34
+ revision = 1,
35
+ ): DeviceSessionState {
36
+ return {
37
+ deviceId: 'device-1',
38
+ accounts: accounts.map((a) => ({ accountId: a.accountId, sessionId: a.sessionId, authuser: 0 })),
39
+ activeAccountId,
40
+ revision,
41
+ updatedAt: 1_720_000_000_000,
42
+ };
43
+ }
44
+
45
+ function user(id: string, over: Partial<User> = {}): User {
46
+ return {
47
+ id,
48
+ publicKey: `pk_${id}`,
49
+ username: `user_${id}`,
50
+ name: { displayName: `User ${id}` },
51
+ ...over,
52
+ } as User;
53
+ }
54
+
55
+ function graphNode(id: string, over: Partial<AccountNode> = {}): AccountNode {
56
+ return {
57
+ accountId: id,
58
+ kind: 'organization',
59
+ parentAccountId: null,
60
+ account: user(id),
61
+ relationship: 'owner',
62
+ callerMembership: null,
63
+ ...over,
64
+ };
65
+ }
66
+
67
+ interface OxyMock {
68
+ getAccessToken: jest.Mock;
69
+ onTokensChanged: jest.Mock;
70
+ listAccounts: jest.Mock;
71
+ getUsersByIds: jest.Mock;
72
+ getFileDownloadUrl: jest.Mock;
73
+ switchToAccount: jest.Mock;
74
+ startCommonsSignIn: jest.Mock;
75
+ pollCommonsSignIn: jest.Mock;
76
+ claimSessionByToken: jest.Mock;
77
+ signInWithSharedIdentity: jest.Mock;
78
+ /**
79
+ * Test helper: set the current access token and fire every registered
80
+ * `onTokensChanged` listener (mirrors `OxyServices.setTokens`/`clearTokens`).
81
+ * With no listener yet registered (before `start()`), it just sets the token.
82
+ */
83
+ emitTokenChange: (token: string | null) => void;
84
+ }
85
+
86
+ function makeOxy(): OxyMock {
87
+ const tokenListeners = new Set<(token: string | null) => void>();
88
+ // Authenticated by default (mirrors a warm start with a planted bearer).
89
+ let currentToken: string | null = 'access-token';
90
+ return {
91
+ getAccessToken: jest.fn(() => currentToken),
92
+ onTokensChanged: jest.fn((listener: (token: string | null) => void) => {
93
+ tokenListeners.add(listener);
94
+ return () => tokenListeners.delete(listener);
95
+ }),
96
+ listAccounts: jest.fn().mockResolvedValue([]),
97
+ getUsersByIds: jest.fn().mockResolvedValue([]),
98
+ getFileDownloadUrl: jest.fn((id: string) => `https://cdn/${id}`),
99
+ switchToAccount: jest.fn(),
100
+ startCommonsSignIn: jest.fn(),
101
+ pollCommonsSignIn: jest.fn(),
102
+ claimSessionByToken: jest.fn(),
103
+ signInWithSharedIdentity: jest.fn().mockResolvedValue(null),
104
+ emitTokenChange: (token: string | null) => {
105
+ currentToken = token;
106
+ for (const listener of tokenListeners) {
107
+ listener(token);
108
+ }
109
+ },
110
+ };
111
+ }
112
+
113
+ /** Flush pending microtasks (a `start()`-triggered `refresh()` cannot be awaited directly). */
114
+ const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
115
+
116
+ interface Harness {
117
+ controller: AccountDialogController;
118
+ oxy: OxyMock;
119
+ sc: TestSessionClient;
120
+ commitSession: jest.Mock;
121
+ onSignedIn: jest.Mock;
122
+ }
123
+
124
+ function makeHarness(over: Partial<{ clientId: string | null }> = {}): Harness {
125
+ const oxy = makeOxy();
126
+ const sc = new TestSessionClient(host());
127
+ const commitSession = jest.fn().mockResolvedValue(undefined);
128
+ const onSignedIn = jest.fn();
129
+ const controller = createAccountDialogController({
130
+ oxyServices: oxy as unknown as OxyServices,
131
+ sessionClient: sc,
132
+ clientId: 'clientId' in over ? over.clientId : 'oxy_dk_test',
133
+ commitSession,
134
+ onSignedIn,
135
+ pollIntervalMs: 1000,
136
+ });
137
+ return { controller, oxy, sc, commitSession, onSignedIn };
138
+ }
139
+
140
+ describe('AccountDialogController — initial + views', () => {
141
+ it('starts on the accounts view with an empty list and idle sign-in', () => {
142
+ const { controller } = makeHarness();
143
+ const snap = controller.getSnapshot();
144
+ expect(snap.view).toBe('accounts');
145
+ expect(snap.accounts).toEqual([]);
146
+ expect(snap.activeAccountId).toBeNull();
147
+ expect(snap.loading).toBe(false);
148
+ expect(snap.switchingAccountId).toBeNull();
149
+ expect(snap.signIn.phase).toBe('idle');
150
+ });
151
+
152
+ it('setView / add / close move between views and notify subscribers', () => {
153
+ const { controller } = makeHarness();
154
+ const seen: string[] = [];
155
+ controller.subscribe((s) => seen.push(s.view));
156
+
157
+ controller.add();
158
+ expect(controller.getSnapshot().view).toBe('add');
159
+ controller.setView('signin');
160
+ expect(controller.getSnapshot().view).toBe('signin');
161
+ controller.close();
162
+ expect(controller.getSnapshot().view).toBe('accounts');
163
+
164
+ expect(seen).toEqual(['add', 'signin', 'accounts']);
165
+ });
166
+
167
+ it('getSnapshot returns a stable reference until a change occurs', () => {
168
+ const { controller } = makeHarness();
169
+ const a = controller.getSnapshot();
170
+ expect(controller.getSnapshot()).toBe(a);
171
+ controller.setView('add');
172
+ expect(controller.getSnapshot()).not.toBe(a);
173
+ });
174
+ });
175
+
176
+ describe('AccountDialogController — account list', () => {
177
+ it('refresh loads graph + profiles and projects the unified list', async () => {
178
+ const { controller, oxy, sc } = makeHarness();
179
+ sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
180
+ oxy.getUsersByIds.mockResolvedValue([user('a1'), user('org1')]);
181
+ oxy.listAccounts.mockResolvedValue([graphNode('org1')]);
182
+
183
+ await controller.refresh();
184
+
185
+ const snap = controller.getSnapshot();
186
+ expect(oxy.getUsersByIds).toHaveBeenCalledWith(['a1', 'org1']);
187
+ expect(snap.accounts.map((r) => r.accountId)).toEqual(['a1', 'org1']);
188
+ expect(snap.activeAccountId).toBe('a1');
189
+ expect(snap.accounts[0].isCurrent).toBe(true);
190
+ expect(snap.accounts[1].onDevice).toBe(false);
191
+ expect(snap.loading).toBe(false);
192
+ });
193
+
194
+ it('start subscribes to SessionClient so a device-state change re-projects', async () => {
195
+ const { controller, oxy, sc } = makeHarness();
196
+ oxy.getUsersByIds.mockResolvedValue([user('a1')]);
197
+ controller.start();
198
+ await Promise.resolve();
199
+ await Promise.resolve();
200
+
201
+ sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
202
+ // The subscription re-projects synchronously from the new device state.
203
+ expect(controller.getSnapshot().activeAccountId).toBe('a1');
204
+ controller.destroy();
205
+ });
206
+
207
+ it('keeps device rows and surfaces the error when listAccounts fails', async () => {
208
+ const { controller, oxy, sc } = makeHarness();
209
+ sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
210
+ oxy.getUsersByIds.mockResolvedValue([user('a1')]);
211
+ oxy.listAccounts.mockRejectedValue(new Error('graph boom'));
212
+
213
+ await controller.refresh();
214
+
215
+ const snap = controller.getSnapshot();
216
+ expect(snap.error).toBe('graph boom');
217
+ expect(snap.accounts.map((r) => r.accountId)).toEqual(['a1']);
218
+ });
219
+ });
220
+
221
+ describe('AccountDialogController — auth-gated graph fetch (prod sign-out fix)', () => {
222
+ it('start() while signed out does NOT call the private listAccounts / getUsersByIds and does not error', async () => {
223
+ const { controller, oxy, sc } = makeHarness();
224
+ oxy.emitTokenChange(null); // cold boot: no bearer planted yet (no listeners registered pre-start)
225
+ sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
226
+
227
+ controller.start();
228
+ await flush();
229
+
230
+ expect(oxy.listAccounts).not.toHaveBeenCalled();
231
+ expect(oxy.getUsersByIds).not.toHaveBeenCalled();
232
+ const snap = controller.getSnapshot();
233
+ expect(snap.error).toBeNull();
234
+ expect(snap.loading).toBe(false);
235
+ controller.destroy();
236
+ });
237
+
238
+ it('refresh() while signed out re-projects device-only and skips the network call', async () => {
239
+ const { controller, oxy } = makeHarness();
240
+ oxy.emitTokenChange(null);
241
+
242
+ await controller.refresh();
243
+
244
+ expect(oxy.listAccounts).not.toHaveBeenCalled();
245
+ const snap = controller.getSnapshot();
246
+ expect(snap.loading).toBe(false);
247
+ expect(snap.error).toBeNull();
248
+ });
249
+
250
+ it('start() while authenticated fetches the graph exactly once', async () => {
251
+ const { controller, oxy, sc } = makeHarness();
252
+ sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
253
+ oxy.listAccounts.mockResolvedValue([graphNode('org1')]);
254
+ oxy.getUsersByIds.mockResolvedValue([user('a1'), user('org1')]);
255
+
256
+ controller.start();
257
+ await flush();
258
+
259
+ expect(oxy.listAccounts).toHaveBeenCalledTimes(1);
260
+ expect(controller.getSnapshot().accounts.map((r) => r.accountId)).toEqual(['a1', 'org1']);
261
+ controller.destroy();
262
+ });
263
+
264
+ it('fetches the graph once when the bearer is planted after a signed-out start', async () => {
265
+ const { controller, oxy } = makeHarness();
266
+ oxy.emitTokenChange(null);
267
+ controller.start();
268
+ await flush();
269
+ expect(oxy.listAccounts).not.toHaveBeenCalled();
270
+
271
+ // Cold-boot restore plants the token → onTokensChanged → single graph fetch.
272
+ oxy.emitTokenChange('access-token');
273
+ await flush();
274
+ expect(oxy.listAccounts).toHaveBeenCalledTimes(1);
275
+ controller.destroy();
276
+ });
277
+
278
+ it('drops the graph and re-projects device-only (no fetch) when the token is cleared', async () => {
279
+ const { controller, oxy, sc } = makeHarness();
280
+ sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
281
+ oxy.listAccounts.mockResolvedValue([graphNode('org1')]);
282
+ oxy.getUsersByIds.mockResolvedValue([user('a1'), user('org1')]);
283
+ controller.start();
284
+ await flush();
285
+ expect(controller.getSnapshot().accounts.map((r) => r.accountId)).toEqual(['a1', 'org1']);
286
+
287
+ oxy.listAccounts.mockClear();
288
+ oxy.emitTokenChange(null); // a 401 cleared the bearer
289
+ await flush();
290
+
291
+ expect(oxy.listAccounts).not.toHaveBeenCalled();
292
+ // Graph-only org1 is gone; the device row survives.
293
+ expect(controller.getSnapshot().accounts.map((r) => r.accountId)).toEqual(['a1']);
294
+ controller.destroy();
295
+ });
296
+
297
+ it('does not loop when listAccounts rejects — at most one call per refresh, no re-trigger on device changes', async () => {
298
+ const { controller, oxy, sc } = makeHarness();
299
+ sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
300
+ oxy.getUsersByIds.mockResolvedValue([user('a1')]);
301
+ oxy.listAccounts.mockRejectedValue(new Error('graph boom'));
302
+
303
+ controller.start();
304
+ await flush();
305
+ expect(oxy.listAccounts).toHaveBeenCalledTimes(1);
306
+ expect(controller.getSnapshot().error).toBe('graph boom');
307
+
308
+ // A subsequent device-state push must NOT re-trigger the graph fetch (auth
309
+ // edge unchanged → reconcileAuth is a no-op → no storm).
310
+ sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1', 2));
311
+ await flush();
312
+ expect(oxy.listAccounts).toHaveBeenCalledTimes(1);
313
+ // Device row still rendered despite the graph failure.
314
+ expect(controller.getSnapshot().accounts.map((r) => r.accountId)).toEqual(['a1']);
315
+ controller.destroy();
316
+ });
317
+ });
318
+
319
+ describe('AccountDialogController — switchTo (uniform switch)', () => {
320
+ it('uses SessionClient.switchAccount for an account already on the device', async () => {
321
+ const { controller, oxy, sc } = makeHarness();
322
+ sc.set(state([{ accountId: 'a1', sessionId: 's1' }, { accountId: 'a2', sessionId: 's2' }], 'a1'));
323
+ const switchSpy = jest.spyOn(sc, 'switchAccount').mockResolvedValue(undefined);
324
+ oxy.getUsersByIds.mockResolvedValue([user('a1'), user('a2')]);
325
+
326
+ await controller.switchTo('a2');
327
+
328
+ expect(switchSpy).toHaveBeenCalledWith('a2');
329
+ expect(oxy.switchToAccount).not.toHaveBeenCalled();
330
+ expect(controller.getSnapshot().switchingAccountId).toBeNull();
331
+ });
332
+
333
+ it('mints via oxyServices.switchToAccount + commitSession on first entry into a graph account', async () => {
334
+ const { controller, oxy, sc, commitSession } = makeHarness();
335
+ sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
336
+ const switchSpy = jest.spyOn(sc, 'switchAccount').mockResolvedValue(undefined);
337
+ oxy.switchToAccount.mockResolvedValue({
338
+ sessionId: 'sess-org',
339
+ deviceId: 'device-1',
340
+ expiresAt: '2030-01-01T00:00:00Z',
341
+ accessToken: 'access-org',
342
+ user: user('org1'),
343
+ });
344
+ oxy.getUsersByIds.mockResolvedValue([user('a1'), user('org1')]);
345
+
346
+ await controller.switchTo('org1');
347
+
348
+ expect(oxy.switchToAccount).toHaveBeenCalledWith('org1');
349
+ expect(switchSpy).not.toHaveBeenCalled();
350
+ expect(commitSession).toHaveBeenCalledTimes(1);
351
+ expect(commitSession.mock.calls[0][0]).toMatchObject({ sessionId: 'sess-org', accessToken: 'access-org' });
352
+ });
353
+
354
+ it('falls back to SessionClient.registerAndActivate when no commitSession is supplied', async () => {
355
+ const oxy = makeOxy();
356
+ const sc = new TestSessionClient(host());
357
+ sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
358
+ const registerSpy = jest.spyOn(sc, 'registerAndActivate').mockResolvedValue(undefined);
359
+ oxy.switchToAccount.mockResolvedValue({
360
+ sessionId: 'sess-org',
361
+ deviceId: 'device-1',
362
+ expiresAt: '2030-01-01T00:00:00Z',
363
+ accessToken: 'access-org',
364
+ user: user('org1'),
365
+ });
366
+ const controller = new AccountDialogController({
367
+ oxyServices: oxy as unknown as OxyServices,
368
+ sessionClient: sc,
369
+ clientId: 'oxy_dk_test',
370
+ });
371
+
372
+ await controller.switchTo('org1');
373
+ expect(registerSpy).toHaveBeenCalledWith('org1');
374
+ });
375
+
376
+ it('ignores a concurrent switch while one is in flight', async () => {
377
+ const { controller, oxy, sc } = makeHarness();
378
+ sc.set(state([{ accountId: 'a1', sessionId: 's1' }, { accountId: 'a2', sessionId: 's2' }], 'a1'));
379
+ let release: () => void = () => undefined;
380
+ jest.spyOn(sc, 'switchAccount').mockImplementation(
381
+ () => new Promise<void>((resolve) => { release = resolve; }),
382
+ );
383
+
384
+ const first = controller.switchTo('a2');
385
+ expect(controller.getSnapshot().switchingAccountId).toBe('a2');
386
+ await controller.switchTo('a1'); // ignored — a switch is in flight
387
+ expect(sc.switchAccount).toHaveBeenCalledTimes(1);
388
+
389
+ release();
390
+ await first;
391
+ });
392
+ });
393
+
394
+ describe('AccountDialogController — sign in with Oxy', () => {
395
+ it('completes silently when a shared identity mints a session', async () => {
396
+ const { controller, oxy, commitSession, onSignedIn } = makeHarness();
397
+ const session: SessionLoginResponse = {
398
+ sessionId: 'sess-shared',
399
+ deviceId: 'device-1',
400
+ expiresAt: '2030-01-01T00:00:00Z',
401
+ accessToken: 'access-shared',
402
+ user: { id: 'a1', username: 'user_a1', name: { displayName: 'User a1' } },
403
+ };
404
+ oxy.signInWithSharedIdentity.mockResolvedValue(session);
405
+
406
+ await controller.signInWithOxy();
407
+
408
+ expect(commitSession).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'sess-shared' }));
409
+ expect(onSignedIn).toHaveBeenCalledWith(expect.objectContaining({ id: 'a1' }));
410
+ expect(controller.getSnapshot().view).toBe('accounts');
411
+ expect(controller.getSnapshot().signIn.phase).toBe('idle');
412
+ expect(oxy.startCommonsSignIn).not.toHaveBeenCalled();
413
+ });
414
+
415
+ it('falls through to the QR handoff when no shared identity is present', async () => {
416
+ const { controller, oxy } = makeHarness();
417
+ oxy.signInWithSharedIdentity.mockResolvedValue(null);
418
+ oxy.startCommonsSignIn.mockResolvedValue({
419
+ sessionToken: 'secret-tok',
420
+ authorizeCode: 'AUTH-CODE',
421
+ qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
422
+ expiresAt: Date.now() + 300_000,
423
+ status: 'pending',
424
+ });
425
+
426
+ await controller.signInWithOxy();
427
+
428
+ expect(oxy.startCommonsSignIn).toHaveBeenCalledWith({ clientId: 'oxy_dk_test' });
429
+ const snap = controller.getSnapshot();
430
+ expect(snap.view).toBe('qr');
431
+ expect(snap.signIn.phase).toBe('waiting');
432
+ expect(snap.signIn.authorizeCode).toBe('AUTH-CODE');
433
+ expect(snap.signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
434
+ controller.cancelSignIn();
435
+ });
436
+
437
+ it('errors when showQr is called without a clientId', async () => {
438
+ const { controller } = makeHarness({ clientId: null });
439
+ await controller.showQr();
440
+ const snap = controller.getSnapshot();
441
+ expect(snap.signIn.phase).toBe('error');
442
+ expect(snap.signIn.error).toMatch(/clientId/);
443
+ });
444
+
445
+ it('polls, claims, and commits when the QR flow is authorized', async () => {
446
+ jest.useFakeTimers();
447
+ try {
448
+ const { controller, oxy, commitSession, onSignedIn } = makeHarness();
449
+ oxy.startCommonsSignIn.mockResolvedValue({
450
+ sessionToken: 'secret-tok',
451
+ authorizeCode: 'AUTH-CODE',
452
+ qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
453
+ expiresAt: Date.now() + 600_000,
454
+ status: 'pending',
455
+ });
456
+ oxy.pollCommonsSignIn
457
+ .mockResolvedValueOnce({ authorized: false, status: 'pending' })
458
+ .mockResolvedValueOnce({ authorized: true, sessionId: 'sess-1', status: 'authorized' });
459
+ oxy.claimSessionByToken.mockResolvedValue({
460
+ accessToken: 'access-1',
461
+ sessionId: 'sess-1',
462
+ deviceId: 'device-1',
463
+ expiresAt: '2030-01-01T00:00:00Z',
464
+ user: user('a1'),
465
+ });
466
+
467
+ await controller.showQr();
468
+ expect(controller.getSnapshot().signIn.phase).toBe('waiting');
469
+
470
+ await jest.advanceTimersByTimeAsync(1000); // first poll → pending
471
+ expect(oxy.pollCommonsSignIn).toHaveBeenCalledTimes(1);
472
+
473
+ await jest.advanceTimersByTimeAsync(1000); // second poll → authorized → claim
474
+ expect(oxy.claimSessionByToken).toHaveBeenCalledWith('secret-tok');
475
+ expect(commitSession).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'sess-1', accessToken: 'access-1' }));
476
+ expect(onSignedIn).toHaveBeenCalledWith(expect.objectContaining({ id: 'a1' }));
477
+ expect(controller.getSnapshot().view).toBe('accounts');
478
+ } finally {
479
+ jest.useRealTimers();
480
+ }
481
+ });
482
+
483
+ it('surfaces a denied QR authorization as an error and stops polling', async () => {
484
+ jest.useFakeTimers();
485
+ try {
486
+ const { controller, oxy } = makeHarness();
487
+ oxy.startCommonsSignIn.mockResolvedValue({
488
+ sessionToken: 'secret-tok',
489
+ authorizeCode: 'AUTH-CODE',
490
+ qrPayload: 'oxycommons://approve',
491
+ expiresAt: Date.now() + 600_000,
492
+ status: 'pending',
493
+ });
494
+ oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'cancelled' });
495
+
496
+ await controller.showQr();
497
+ await jest.advanceTimersByTimeAsync(1000);
498
+
499
+ expect(controller.getSnapshot().signIn.phase).toBe('error');
500
+ expect(controller.getSnapshot().signIn.error).toMatch(/denied/i);
501
+
502
+ // No further polls after the terminal error.
503
+ await jest.advanceTimersByTimeAsync(5000);
504
+ expect(oxy.pollCommonsSignIn).toHaveBeenCalledTimes(1);
505
+ } finally {
506
+ jest.useRealTimers();
507
+ }
508
+ });
509
+
510
+ it('cancelSignIn stops the poll and resets to idle', async () => {
511
+ jest.useFakeTimers();
512
+ try {
513
+ const { controller, oxy } = makeHarness();
514
+ oxy.startCommonsSignIn.mockResolvedValue({
515
+ sessionToken: 'secret-tok',
516
+ authorizeCode: 'AUTH-CODE',
517
+ qrPayload: 'oxycommons://approve',
518
+ expiresAt: Date.now() + 600_000,
519
+ status: 'pending',
520
+ });
521
+ oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
522
+
523
+ await controller.showQr();
524
+ controller.cancelSignIn();
525
+ expect(controller.getSnapshot().signIn.phase).toBe('idle');
526
+
527
+ await jest.advanceTimersByTimeAsync(5000);
528
+ expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
529
+ } finally {
530
+ jest.useRealTimers();
531
+ }
532
+ });
533
+ });
534
+
535
+ describe('AccountDialogController — openPasswordAtOxyAuth', () => {
536
+ it('builds the IdP sign-in URL with redirect_uri + client_id and invokes openUrl', () => {
537
+ const oxy = makeOxy();
538
+ const sc = new TestSessionClient(host());
539
+ const openUrl = jest.fn();
540
+ const controller = new AccountDialogController({
541
+ oxyServices: oxy as unknown as OxyServices,
542
+ sessionClient: sc,
543
+ clientId: 'oxy_dk_test',
544
+ openUrl,
545
+ });
546
+
547
+ const url = controller.openPasswordAtOxyAuth({ returnUrl: 'https://mention.earth/', state: 'xyz' });
548
+ const parsed = new URL(url);
549
+ expect(parsed.origin).toBe('https://auth.oxy.so');
550
+ expect(parsed.pathname).toBe('/login');
551
+ expect(parsed.searchParams.get('redirect_uri')).toBe('https://mention.earth/');
552
+ expect(parsed.searchParams.get('client_id')).toBe('oxy_dk_test');
553
+ expect(parsed.searchParams.get('state')).toBe('xyz');
554
+ expect(openUrl).toHaveBeenCalledWith(url);
555
+ });
556
+
557
+ it('honors an idpApex override', () => {
558
+ const oxy = makeOxy();
559
+ const sc = new TestSessionClient(host());
560
+ const controller = new AccountDialogController({
561
+ oxyServices: oxy as unknown as OxyServices,
562
+ sessionClient: sc,
563
+ idpApex: 'alia.onl',
564
+ });
565
+ const url = controller.openPasswordAtOxyAuth({ returnUrl: 'https://alia.onl/' });
566
+ expect(new URL(url).origin).toBe('https://auth.alia.onl');
567
+ });
568
+ });
569
+
570
+ describe('AccountDialogController — lifecycle', () => {
571
+ it('destroy unsubscribes so later device-state changes do not notify', async () => {
572
+ const { controller, oxy, sc } = makeHarness();
573
+ oxy.getUsersByIds.mockResolvedValue([user('a1')]);
574
+ controller.start();
575
+ await Promise.resolve();
576
+ const seen: string[] = [];
577
+ controller.subscribe((s) => seen.push(s.view));
578
+ controller.destroy();
579
+ // destroy clears all listeners; a subsequent state push notifies nobody.
580
+ sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
581
+ expect(seen).toEqual([]);
582
+ });
583
+ });
584
+
585
+ it('createAccountDialogController returns an AccountDialogController instance', () => {
586
+ const { controller } = makeHarness();
587
+ expect(controller).toBeInstanceOf(AccountDialogController);
588
+ });
589
+
590
+ // Ensure the exported type surface is reachable at compile time for binders.
591
+ const _typecheck: MinimalUserData | null = null;
592
+ void _typecheck;