@oxyhq/core 10.1.3 → 10.1.5
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/session/SessionClient.js +35 -0
- package/dist/cjs/session/accountDialogController.js +141 -27
- package/dist/cjs/utils/textNormalization.js +0 -28
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/session/SessionClient.js +36 -1
- package/dist/esm/session/accountDialogController.js +141 -27
- package/dist/esm/utils/textNormalization.js +0 -28
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/session/SessionClient.d.ts +17 -0
- package/dist/types/session/accountDialogController.d.ts +50 -2
- package/dist/types/session/socketLoader.d.ts +2 -0
- package/package.json +2 -2
- package/src/session/SessionClient.ts +36 -0
- package/src/session/__tests__/SessionClient.serverEvents.test.ts +1 -0
- package/src/session/__tests__/SessionClient.socket.test.ts +36 -0
- package/src/session/__tests__/SessionClient.socketFactory.test.ts +1 -0
- package/src/session/__tests__/accountDialogController.test.ts +107 -0
- package/src/session/accountDialogController.ts +150 -27
- package/src/session/socketLoader.ts +2 -0
- package/src/utils/textNormalization.ts +0 -30
|
@@ -129,6 +129,23 @@ export declare class SessionClient {
|
|
|
129
129
|
start(): Promise<void>;
|
|
130
130
|
stop(): void;
|
|
131
131
|
private connectSocket;
|
|
132
|
+
/**
|
|
133
|
+
* Handle the token-free `session_accounts_changed` signal (room `user:<userId>`).
|
|
134
|
+
*
|
|
135
|
+
* Unlike `session_state` (device-scoped, carries the new state to APPLY), this
|
|
136
|
+
* reaches ALL of a user's connected sockets across their devices/origins and is
|
|
137
|
+
* a pure SIGNAL: it carries no token, no secret, and no account bodies. The only
|
|
138
|
+
* trustworthy bit is "something changed for this user", so — matching the
|
|
139
|
+
* `session_state` contract's guidance — we re-fetch our OWN authoritative device
|
|
140
|
+
* state (`bootstrap` → `GET /session/device/state`) and let the existing
|
|
141
|
+
* `applyState` revision guard reconcile it. We never trust any field on the event
|
|
142
|
+
* beyond routing it to the current user.
|
|
143
|
+
*
|
|
144
|
+
* The refetch is a private (bearer) call: the socket only joins `user:<userId>`
|
|
145
|
+
* when authenticated, so a signed-out client never receives this — but we guard
|
|
146
|
+
* the bearer anyway so a race at sign-out can't 401.
|
|
147
|
+
*/
|
|
148
|
+
private onSessionAccountsChanged;
|
|
132
149
|
/**
|
|
133
150
|
* Open the same-origin `BroadcastChannel` (web only). A sibling tab that
|
|
134
151
|
* commits an account switch / sign-out posts a wake ping; on receipt an
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import type { OxyServices } from '../OxyServices';
|
|
29
29
|
import type { SessionLoginResponse, MinimalUserData } from '../models/session';
|
|
30
30
|
import type { SessionClient } from './SessionClient';
|
|
31
|
+
import type { SocketIOFactory } from './socketLoader';
|
|
31
32
|
import { type SwitchableAccount } from './accountProjection';
|
|
32
33
|
/** The dialog's top-level view. */
|
|
33
34
|
export type AccountDialogView = 'accounts' | 'signin' | 'qr' | 'add';
|
|
@@ -103,8 +104,20 @@ export interface AccountDialogControllerOptions {
|
|
|
103
104
|
* `location.origin` normalization in {@link openPasswordAtOxyAuth}.
|
|
104
105
|
*/
|
|
105
106
|
authRedirectUri?: string | null;
|
|
106
|
-
/**
|
|
107
|
+
/**
|
|
108
|
+
* QR device-flow FALLBACK poll interval in ms (default 12000). The primary
|
|
109
|
+
* approval signal is the `/auth-session` socket's `auth_update` event (instant);
|
|
110
|
+
* this slow poll is only the safety net for when the socket can't connect.
|
|
111
|
+
*/
|
|
107
112
|
pollIntervalMs?: number;
|
|
113
|
+
/**
|
|
114
|
+
* Statically-injected `socket.io-client` factory (its `io` export), same as
|
|
115
|
+
* {@link SessionClient}'s. When provided, the QR flow subscribes to the
|
|
116
|
+
* `/auth-session` namespace for an INSTANT `auth_update` wake instead of relying
|
|
117
|
+
* on the slow fallback poll. Absent on web builds without a bundled `io` and in
|
|
118
|
+
* headless/core usage → the controller silently degrades to poll-only.
|
|
119
|
+
*/
|
|
120
|
+
socketFactory?: SocketIOFactory;
|
|
108
121
|
/**
|
|
109
122
|
* Optional URL opener. When provided, `openPasswordAtOxyAuth` invokes it with
|
|
110
123
|
* the built URL in addition to returning it (web: `location.assign`; native:
|
|
@@ -135,6 +148,7 @@ export declare class AccountDialogController {
|
|
|
135
148
|
private readonly pollIntervalMs;
|
|
136
149
|
private readonly openUrl?;
|
|
137
150
|
private readonly canOpenApp?;
|
|
151
|
+
private readonly socketFactory?;
|
|
138
152
|
private readonly listeners;
|
|
139
153
|
private view;
|
|
140
154
|
private graph;
|
|
@@ -146,6 +160,18 @@ export declare class AccountDialogController {
|
|
|
146
160
|
/** The secret device-flow token of the active QR flow (never surfaced). */
|
|
147
161
|
private signInToken;
|
|
148
162
|
private pollTimer;
|
|
163
|
+
/**
|
|
164
|
+
* The `/auth-session` socket for the active QR flow, or null (poll-only). Its
|
|
165
|
+
* `auth_update` event wakes {@link pollOnce} instantly instead of waiting for the
|
|
166
|
+
* slow fallback timer.
|
|
167
|
+
*/
|
|
168
|
+
private authSessionSocket;
|
|
169
|
+
/**
|
|
170
|
+
* Guards {@link pollOnce} against re-entrancy: the fallback timer and a socket
|
|
171
|
+
* `auth_update` wake can fire together — without this both could claim the
|
|
172
|
+
* single-use token concurrently.
|
|
173
|
+
*/
|
|
174
|
+
private pollInFlight;
|
|
149
175
|
private unsubscribeSession;
|
|
150
176
|
private unsubscribeTokens;
|
|
151
177
|
/** Last-observed SDK auth readiness (a planted bearer). Drives the fetch edge. */
|
|
@@ -249,7 +275,7 @@ export declare class AccountDialogController {
|
|
|
249
275
|
* probe/open failure is logged and swallowed — the QR/polling fallback remains.
|
|
250
276
|
*/
|
|
251
277
|
private maybeOpenCommons;
|
|
252
|
-
/** Tear down the active sign-in device flow (timers + token) and reset to idle. */
|
|
278
|
+
/** Tear down the active sign-in device flow (timers + socket + token) and reset to idle. */
|
|
253
279
|
cancelSignIn(): void;
|
|
254
280
|
/**
|
|
255
281
|
* Build (and, when an `openUrl` handler was supplied, open) the auth.oxy.so
|
|
@@ -271,6 +297,13 @@ export declare class AccountDialogController {
|
|
|
271
297
|
redirectUri?: string;
|
|
272
298
|
}): Promise<string>;
|
|
273
299
|
private scheduleNextPoll;
|
|
300
|
+
/**
|
|
301
|
+
* Run one status check + (on approval) claim. Triggered by the fallback timer
|
|
302
|
+
* AND by the `/auth-session` socket's `auth_update` wake, so it is guarded
|
|
303
|
+
* against concurrent entry: whichever fires first claims the single-use token;
|
|
304
|
+
* the other no-ops. The `auth_update` payload is never trusted — this always
|
|
305
|
+
* re-checks the authoritative status via `pollCommonsSignIn`.
|
|
306
|
+
*/
|
|
274
307
|
private pollOnce;
|
|
275
308
|
private claimAndComplete;
|
|
276
309
|
/**
|
|
@@ -286,6 +319,21 @@ export declare class AccountDialogController {
|
|
|
286
319
|
private commitAuthorizedSession;
|
|
287
320
|
private failSignIn;
|
|
288
321
|
private clearPollTimer;
|
|
322
|
+
/**
|
|
323
|
+
* Subscribe the active QR flow to the `/auth-session` namespace so the API's
|
|
324
|
+
* `auth_update` event wakes {@link pollOnce} the instant the approval lands.
|
|
325
|
+
*
|
|
326
|
+
* The join is keyed by the secret `sessionToken` (the server's `auth:<token>`
|
|
327
|
+
* room, joined by emitting `join`) and re-issued on every (re)connect so it
|
|
328
|
+
* survives socket drops. `auth_update` is treated as a pure SIGNAL — the payload
|
|
329
|
+
* is never trusted; `pollOnce` re-checks the authoritative status and claims.
|
|
330
|
+
*
|
|
331
|
+
* No-op (poll-only) when no `socketFactory` was injected (web without a bundled
|
|
332
|
+
* `io`, headless/core usage, tests). The namespace needs no auth.
|
|
333
|
+
*/
|
|
334
|
+
private openAuthSessionSocket;
|
|
335
|
+
/** Tear down the `/auth-session` socket, if any. Idempotent. */
|
|
336
|
+
private closeAuthSessionSocket;
|
|
289
337
|
private setSignIn;
|
|
290
338
|
private computeSnapshot;
|
|
291
339
|
/** Recompute the snapshot and notify subscribers. */
|
|
@@ -2,6 +2,8 @@ export interface MinimalSocket {
|
|
|
2
2
|
connected: boolean;
|
|
3
3
|
on(event: string, handler: (...args: unknown[]) => void): void;
|
|
4
4
|
off(event: string, handler?: (...args: unknown[]) => void): void;
|
|
5
|
+
/** Client→server emit (e.g. joining the `/auth-session` room for a QR flow). */
|
|
6
|
+
emit(event: string, ...args: unknown[]): void;
|
|
5
7
|
connect(): void;
|
|
6
8
|
disconnect(): void;
|
|
7
9
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxyhq/core",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.5",
|
|
4
4
|
"description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -94,7 +94,7 @@
|
|
|
94
94
|
}
|
|
95
95
|
},
|
|
96
96
|
"dependencies": {
|
|
97
|
-
"@oxyhq/contracts": "^0.14.
|
|
97
|
+
"@oxyhq/contracts": "^0.14.1",
|
|
98
98
|
"@oxyhq/protocol": "^0.1.5",
|
|
99
99
|
"bip39": "^3.1.0",
|
|
100
100
|
"buffer": "^6.0.3",
|
|
@@ -2,6 +2,8 @@ import {
|
|
|
2
2
|
deviceSessionStateSchema,
|
|
3
3
|
deviceSessionSyncSchema,
|
|
4
4
|
safeParseContract,
|
|
5
|
+
SESSION_ACCOUNTS_CHANGED_EVENT,
|
|
6
|
+
sessionAccountsChangedEventSchema,
|
|
5
7
|
type DeviceSessionState,
|
|
6
8
|
} from '@oxyhq/contracts';
|
|
7
9
|
import { logger } from '../utils/loggerUtils';
|
|
@@ -407,6 +409,9 @@ export class SessionClient {
|
|
|
407
409
|
});
|
|
408
410
|
}
|
|
409
411
|
});
|
|
412
|
+
socket.on(SESSION_ACCOUNTS_CHANGED_EVENT, (payload: unknown) => {
|
|
413
|
+
this.onSessionAccountsChanged(payload);
|
|
414
|
+
});
|
|
410
415
|
this.socket = socket;
|
|
411
416
|
// (Re)bind app-facing server-event subscriptions on the fresh socket.
|
|
412
417
|
this.boundServerEvents.clear();
|
|
@@ -415,6 +420,37 @@ export class SessionClient {
|
|
|
415
420
|
}
|
|
416
421
|
}
|
|
417
422
|
|
|
423
|
+
/**
|
|
424
|
+
* Handle the token-free `session_accounts_changed` signal (room `user:<userId>`).
|
|
425
|
+
*
|
|
426
|
+
* Unlike `session_state` (device-scoped, carries the new state to APPLY), this
|
|
427
|
+
* reaches ALL of a user's connected sockets across their devices/origins and is
|
|
428
|
+
* a pure SIGNAL: it carries no token, no secret, and no account bodies. The only
|
|
429
|
+
* trustworthy bit is "something changed for this user", so — matching the
|
|
430
|
+
* `session_state` contract's guidance — we re-fetch our OWN authoritative device
|
|
431
|
+
* state (`bootstrap` → `GET /session/device/state`) and let the existing
|
|
432
|
+
* `applyState` revision guard reconcile it. We never trust any field on the event
|
|
433
|
+
* beyond routing it to the current user.
|
|
434
|
+
*
|
|
435
|
+
* The refetch is a private (bearer) call: the socket only joins `user:<userId>`
|
|
436
|
+
* when authenticated, so a signed-out client never receives this — but we guard
|
|
437
|
+
* the bearer anyway so a race at sign-out can't 401.
|
|
438
|
+
*/
|
|
439
|
+
private onSessionAccountsChanged(payload: unknown): void {
|
|
440
|
+
const event = safeParseContract(sessionAccountsChangedEventSchema, payload);
|
|
441
|
+
if (!event) {
|
|
442
|
+
logger.warn('[SessionClient] discarded invalid session_accounts_changed', { component: 'SessionClient' });
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
// The socket is in `user:<activeUserId>` for the planted bearer, so this should
|
|
446
|
+
// always be the current user; ignore a foreign id defensively (out-of-band relay).
|
|
447
|
+
if (event.userId !== this.host.getCurrentAccountId()) return;
|
|
448
|
+
if (!this.host.getAccessToken()) return;
|
|
449
|
+
void this.bootstrap().catch((error) => {
|
|
450
|
+
logger.warn('[SessionClient] session_accounts_changed refetch failed', { component: 'SessionClient' }, error);
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
418
454
|
/**
|
|
419
455
|
* Open the same-origin `BroadcastChannel` (web only). A sibling tab that
|
|
420
456
|
* commits an account switch / sign-out posts a wake ping; on receipt an
|
|
@@ -8,6 +8,7 @@ class FakeSocket implements MinimalSocket {
|
|
|
8
8
|
handlers = new Map<string, Handler[]>();
|
|
9
9
|
on(event: string, cb: Handler) { const l = this.handlers.get(event) ?? []; l.push(cb); this.handlers.set(event, l); }
|
|
10
10
|
off(event: string, cb?: Handler) { if (!cb) { this.handlers.delete(event); return; } this.handlers.set(event, (this.handlers.get(event) ?? []).filter((h) => h !== cb)); }
|
|
11
|
+
emit(_event: string, ..._args: unknown[]) { /* client→server emit, unused here */ }
|
|
11
12
|
connect() { this.connected = true; }
|
|
12
13
|
disconnect() { this.connected = false; }
|
|
13
14
|
emitServer(event: string, payload: unknown) { for (const h of this.handlers.get(event) ?? []) h(payload); }
|
|
@@ -132,6 +132,42 @@ describe('SessionClient socket', () => {
|
|
|
132
132
|
expect(fakeSocket.connected).toBe(false);
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
+
it('session_accounts_changed for the current user refetches device state (GET /session/device/state)', async () => {
|
|
136
|
+
const makeRequest = jest.fn().mockResolvedValue(SYNC(1));
|
|
137
|
+
const host = makeHost({ makeRequest, getCurrentAccountId: () => 'a1' });
|
|
138
|
+
const c = new SessionClient(host);
|
|
139
|
+
await c.start();
|
|
140
|
+
makeRequest.mockClear();
|
|
141
|
+
fakeSocket.trigger('session_accounts_changed', { userId: 'a1', revision: 5, reason: 'add' });
|
|
142
|
+
await Promise.resolve();
|
|
143
|
+
expect(makeRequest).toHaveBeenCalledWith('GET', '/session/device/state', undefined, { cache: false });
|
|
144
|
+
c.stop();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('session_accounts_changed for a DIFFERENT user is ignored (no refetch)', async () => {
|
|
148
|
+
const makeRequest = jest.fn().mockResolvedValue(SYNC(1));
|
|
149
|
+
const host = makeHost({ makeRequest, getCurrentAccountId: () => 'a1' });
|
|
150
|
+
const c = new SessionClient(host);
|
|
151
|
+
await c.start();
|
|
152
|
+
makeRequest.mockClear();
|
|
153
|
+
fakeSocket.trigger('session_accounts_changed', { userId: 'someone-else', revision: 5, reason: 'switch' });
|
|
154
|
+
await Promise.resolve();
|
|
155
|
+
expect(makeRequest).not.toHaveBeenCalled();
|
|
156
|
+
c.stop();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('session_accounts_changed drops a malformed payload without refetching', async () => {
|
|
160
|
+
const makeRequest = jest.fn().mockResolvedValue(SYNC(1));
|
|
161
|
+
const host = makeHost({ makeRequest, getCurrentAccountId: () => 'a1' });
|
|
162
|
+
const c = new SessionClient(host);
|
|
163
|
+
await c.start();
|
|
164
|
+
makeRequest.mockClear();
|
|
165
|
+
fakeSocket.trigger('session_accounts_changed', { userId: 'a1', reason: 'not-a-real-reason' });
|
|
166
|
+
await Promise.resolve();
|
|
167
|
+
expect(makeRequest).not.toHaveBeenCalled();
|
|
168
|
+
c.stop();
|
|
169
|
+
});
|
|
170
|
+
|
|
135
171
|
it('a socket-pushed empty state fires onUnauthenticated with the PUSH origin (bug #4)', async () => {
|
|
136
172
|
const onUnauthenticated = jest.fn();
|
|
137
173
|
const c = new SessionClient(makeHost(), { onUnauthenticated });
|
|
@@ -20,6 +20,7 @@ class FakeSocket implements MinimalSocket {
|
|
|
20
20
|
handlers = new Map<string, Handler[]>();
|
|
21
21
|
on(event: string, cb: Handler) { const l = this.handlers.get(event) ?? []; l.push(cb); this.handlers.set(event, l); }
|
|
22
22
|
off(event: string, cb?: Handler) { if (!cb) { this.handlers.delete(event); return; } this.handlers.set(event, (this.handlers.get(event) ?? []).filter((h) => h !== cb)); }
|
|
23
|
+
emit(_event: string, ..._args: unknown[]) { /* no-op: device socket never emits */ }
|
|
23
24
|
connect() { this.connected = true; }
|
|
24
25
|
disconnect() { this.connected = false; }
|
|
25
26
|
}
|
|
@@ -4,6 +4,7 @@ import type { User } from '../../models/interfaces';
|
|
|
4
4
|
import type { SessionLoginResponse, MinimalUserData } from '../../models/session';
|
|
5
5
|
import type { AccountNode } from '../../mixins/OxyServices.accounts';
|
|
6
6
|
import { SessionClient, type SessionClientHost } from '../SessionClient';
|
|
7
|
+
import type { MinimalSocket, SocketIOFactory } from '../socketLoader';
|
|
7
8
|
import { logger } from '../../utils/loggerUtils';
|
|
8
9
|
import {
|
|
9
10
|
AccountDialogController,
|
|
@@ -68,6 +69,7 @@ function graphNode(id: string, over: Partial<AccountNode> = {}): AccountNode {
|
|
|
68
69
|
|
|
69
70
|
interface OxyMock {
|
|
70
71
|
getAccessToken: jest.Mock;
|
|
72
|
+
getBaseURL: jest.Mock;
|
|
71
73
|
onTokensChanged: jest.Mock;
|
|
72
74
|
listAccounts: jest.Mock;
|
|
73
75
|
getUsersByIds: jest.Mock;
|
|
@@ -91,6 +93,7 @@ function makeOxy(): OxyMock {
|
|
|
91
93
|
let currentToken: string | null = 'access-token';
|
|
92
94
|
return {
|
|
93
95
|
getAccessToken: jest.fn(() => currentToken),
|
|
96
|
+
getBaseURL: jest.fn(() => 'http://test.invalid'),
|
|
94
97
|
onTokensChanged: jest.fn((listener: (token: string | null) => void) => {
|
|
95
98
|
tokenListeners.add(listener);
|
|
96
99
|
return () => tokenListeners.delete(listener);
|
|
@@ -727,6 +730,110 @@ describe('AccountDialogController — openPasswordAtOxyAuth', () => {
|
|
|
727
730
|
});
|
|
728
731
|
});
|
|
729
732
|
|
|
733
|
+
describe('AccountDialogController — /auth-session socket (instant QR wake)', () => {
|
|
734
|
+
type Handler = (...args: unknown[]) => void;
|
|
735
|
+
class FakeAuthSocket implements MinimalSocket {
|
|
736
|
+
connected = false;
|
|
737
|
+
disconnected = false;
|
|
738
|
+
handlers = new Map<string, Handler[]>();
|
|
739
|
+
emitted: Array<{ event: string; args: unknown[] }> = [];
|
|
740
|
+
on(event: string, cb: Handler) { const l = this.handlers.get(event) ?? []; l.push(cb); this.handlers.set(event, l); }
|
|
741
|
+
off(event: string, cb?: Handler) { if (!cb) { this.handlers.delete(event); return; } this.handlers.set(event, (this.handlers.get(event) ?? []).filter((h) => h !== cb)); }
|
|
742
|
+
emit(event: string, ...args: unknown[]) { this.emitted.push({ event, args }); }
|
|
743
|
+
connect() { this.connected = true; }
|
|
744
|
+
disconnect() { this.connected = false; this.disconnected = true; }
|
|
745
|
+
/** Simulate a server→client push on this socket. */
|
|
746
|
+
server(event: string, payload?: unknown) { for (const h of this.handlers.get(event) ?? []) h(payload); }
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const START_HANDLE = {
|
|
750
|
+
sessionToken: 'secret-tok',
|
|
751
|
+
authorizeCode: 'AUTH-CODE',
|
|
752
|
+
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
753
|
+
expiresAt: Date.now() + 600_000,
|
|
754
|
+
status: 'pending' as const,
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
function makeSocketHarness(): { controller: AccountDialogController; oxy: OxyMock; created: () => FakeAuthSocket | null; factory: jest.Mock; commitSession: jest.Mock } {
|
|
758
|
+
const oxy = makeOxy();
|
|
759
|
+
oxy.startCommonsSignIn.mockResolvedValue(START_HANDLE);
|
|
760
|
+
let socket: FakeAuthSocket | null = null;
|
|
761
|
+
const factory = jest.fn((_uri: string, _opts?: Record<string, unknown>): MinimalSocket => {
|
|
762
|
+
socket = new FakeAuthSocket();
|
|
763
|
+
socket.connected = true; // real io autoConnect resolves before we inspect
|
|
764
|
+
return socket;
|
|
765
|
+
});
|
|
766
|
+
const commitSession = jest.fn().mockResolvedValue(undefined);
|
|
767
|
+
const controller = new AccountDialogController({
|
|
768
|
+
oxyServices: oxy as unknown as OxyServices,
|
|
769
|
+
sessionClient: new TestSessionClient(host()),
|
|
770
|
+
clientId: 'oxy_dk_test',
|
|
771
|
+
commitSession,
|
|
772
|
+
socketFactory: factory as unknown as SocketIOFactory,
|
|
773
|
+
});
|
|
774
|
+
return { controller, oxy, created: () => socket, factory, commitSession };
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
it('connects to /auth-session, joins the flow room, and wakes the claim on auth_update — no timer advance', async () => {
|
|
778
|
+
const { controller, oxy, created, factory, commitSession } = makeSocketHarness();
|
|
779
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: true, sessionId: 'sess-1', status: 'authorized' });
|
|
780
|
+
oxy.claimSessionByToken.mockResolvedValue({
|
|
781
|
+
accessToken: 'access-1', sessionId: 'sess-1', deviceId: 'device-1', expiresAt: '2030-01-01T00:00:00Z', user: user('a1'),
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
await controller.showQr();
|
|
785
|
+
|
|
786
|
+
expect(factory).toHaveBeenCalledWith('http://test.invalid/auth-session', expect.any(Object));
|
|
787
|
+
const sock = created();
|
|
788
|
+
if (!sock) throw new Error('socket not created');
|
|
789
|
+
expect(sock.emitted).toContainEqual({ event: 'join', args: ['secret-tok'] });
|
|
790
|
+
|
|
791
|
+
// The server pushes auth_update → immediate status check + claim, without any poll timer firing.
|
|
792
|
+
sock.server('auth_update', { status: 'authorized', sessionId: 'sess-1' });
|
|
793
|
+
await flush();
|
|
794
|
+
|
|
795
|
+
expect(oxy.pollCommonsSignIn).toHaveBeenCalledWith('secret-tok');
|
|
796
|
+
expect(oxy.claimSessionByToken).toHaveBeenCalledWith('secret-tok');
|
|
797
|
+
expect(commitSession).toHaveBeenCalled();
|
|
798
|
+
expect(controller.getSnapshot().view).toBe('accounts');
|
|
799
|
+
expect(sock.disconnected).toBe(true); // torn down on completion
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
it('re-joins the room on reconnect (connect event) and tears the socket down on cancelSignIn', async () => {
|
|
803
|
+
const { controller, oxy, created } = makeSocketHarness();
|
|
804
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
|
|
805
|
+
|
|
806
|
+
await controller.showQr();
|
|
807
|
+
const sock = created();
|
|
808
|
+
if (!sock) throw new Error('socket not created');
|
|
809
|
+
expect(sock.emitted.filter((e) => e.event === 'join')).toHaveLength(1);
|
|
810
|
+
|
|
811
|
+
// A reconnect fires `connect` again → the join is re-issued so it survives drops.
|
|
812
|
+
sock.server('connect');
|
|
813
|
+
expect(sock.emitted.filter((e) => e.event === 'join')).toHaveLength(2);
|
|
814
|
+
|
|
815
|
+
controller.cancelSignIn();
|
|
816
|
+
expect(sock.disconnected).toBe(true);
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
it('a stale auth_update after the flow was cancelled does not re-poll', async () => {
|
|
820
|
+
const { controller, oxy, created } = makeSocketHarness();
|
|
821
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
|
|
822
|
+
|
|
823
|
+
await controller.showQr();
|
|
824
|
+
const sock = created();
|
|
825
|
+
if (!sock) throw new Error('socket not created');
|
|
826
|
+
oxy.pollCommonsSignIn.mockClear();
|
|
827
|
+
|
|
828
|
+
controller.cancelSignIn();
|
|
829
|
+
// Even if a late auth_update slips through on the (now-detached) socket, the
|
|
830
|
+
// superseded-token guard drops it.
|
|
831
|
+
sock.server('auth_update', { status: 'authorized' });
|
|
832
|
+
await flush();
|
|
833
|
+
expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
|
|
730
837
|
describe('AccountDialogController — lifecycle', () => {
|
|
731
838
|
it('destroy unsubscribes so later device-state changes do not notify', async () => {
|
|
732
839
|
const { controller, oxy, sc } = makeHarness();
|