import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import type { Socket, ManagerOptions, SocketOptions } from 'socket.io-client'; import { WebAppConnection } from '../web-app-connection.service'; import { STORED_WEB_SESSION_VERSION, WEB_SESSION_STORAGE_KEY_PREFIX, WebAppSessionError, type StoredWebSession, type WebAppSessionFetch, type WebSessionStorageLike, } from '../web-app-session.service'; /** * Minimal Socket.IO mock — enough to: * - capture the `auth` CALLBACK and resolve it on demand (`resolveAuth`) * - fire arbitrary server-side events on demand (`emitFromServer`) * - record client-emitted events (`emittedToServer`) * - count manual `connect()` re-dials (server force-disconnect path) */ class MockWebSocket { public listeners = new Map void>>(); public emittedToServer: Array<{ event: string; payload: unknown }> = []; public disconnectCalled = 0; public connectCalled = 0; private readonly authFn: (handshakeCallback: (data: object) => void) => void; constructor( public url: string, opts: Partial, ) { if (typeof opts.auth !== 'function') { throw new Error('expected auth to be passed as a callback'); } this.authFn = opts.auth as (handshakeCallback: (data: object) => void) => void; } /** Test helper — run the handshake auth callback like socket.io would before a handshake. */ resolveAuth(): Promise> { return new Promise((resolve) => { this.authFn((data) => resolve(data as Record)); }); } on(event: string, listener: (...args: unknown[]) => void): this { const list = this.listeners.get(event) ?? []; list.push(listener); this.listeners.set(event, list); return this; } off(event: string, listener: (...args: unknown[]) => void): this { const list = this.listeners.get(event) ?? []; this.listeners.set( event, list.filter((handler) => handler !== listener), ); return this; } emit(event: string, ...args: unknown[]): this { this.emittedToServer.push({ event, payload: args[0] }); return this; } connect(): this { this.connectCalled += 1; return this; } disconnect(): this { this.disconnectCalled += 1; return this; } /** Test helper — fire a server-originated event into the client. */ emitFromServer(event: string, ...args: unknown[]): void { (this.listeners.get(event) ?? []).forEach((listener) => listener(...args)); } } const TEST_SESSION_BASE = 'http://localhost:14080'; const TEST_PHYHUB = 'http://localhost:14400'; const okJson = (body: unknown): Response => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' }, }); const sessionBody = ( token: string, refreshToken: string, expiresInSeconds: number, deadlineInSeconds = 3600, ): Record => ({ token, refreshToken, expiresIn: expiresInSeconds, expiresAt: new Date(Date.now() + expiresInSeconds * 1000).toISOString(), deadlineInSeconds, }); let originalSetTimeout: typeof setTimeout; let scheduledTimers: Array<{ delay: number; fn: () => void }> = []; beforeEach(() => { scheduledTimers = []; originalSetTimeout = globalThis.setTimeout; // Capture timers without actually scheduling them so the tests are // deterministic and don't have to wait. Returns an integer handle. globalThis.setTimeout = ((fn: () => void, delay: number) => { const handle = scheduledTimers.length + 1; scheduledTimers.push({ delay, fn }); return handle as unknown as ReturnType; }) as typeof setTimeout; }); afterEach(() => { globalThis.setTimeout = originalSetTimeout; }); const flushAsync = (): Promise => new Promise((resolve) => originalSetTimeout(() => resolve(), 0)); interface ConnectedFixture { connection: WebAppConnection; socket: MockWebSocket; requestBodies: string[]; } /** * Build a connected WebAppConnection: mint succeeds with `firstExpiresIn`, * every later exchange (renewal) is answered by `renewalResponder`. */ const connectFixture = async (options: { firstExpiresIn: number; renewalResponder: (callIndex: number) => Response | Promise; refreshLeadMs?: number; }): Promise => { let callCount = 0; const requestBodies: string[] = []; const fakeFetch: WebAppSessionFetch = (_, init) => { callCount += 1; requestBodies.push(init?.body ?? ''); if (callCount === 1) { return Promise.resolve(okJson(sessionBody('jwt-1', 'grant-1', options.firstExpiresIn))); } return Promise.resolve(options.renewalResponder(callCount)); }; let captured: MockWebSocket | null = null; const connection = new WebAppConnection({ urlId: 'lobby-dog', sessionBaseUrl: TEST_SESSION_BASE, phyhubUrl: TEST_PHYHUB, code: 'abc12345', refreshLeadMs: options.refreshLeadMs ?? 60_000, fetch: fakeFetch, ioFactory: (url, opts) => { captured = new MockWebSocket(url, opts); // Resolve `connect()` by firing `webAppAuthenticated` async. originalSetTimeout(() => captured!.emitFromServer('webAppAuthenticated', { status: 'success' }), 0); return captured as unknown as Socket; }, }); await connection.connect(); return { connection, socket: captured!, requestBodies }; }; describe('WebAppConnection.connect()', () => { it('mints from the claim code and resolves the auth callback with webAppJwt', async () => { const { connection, socket, requestBodies } = await connectFixture({ firstExpiresIn: 900, renewalResponder: () => okJson(sessionBody('jwt-2', 'grant-2', 900)), }); expect(socket.url).toBe(TEST_PHYHUB); expect(requestBodies[0]).toBe(JSON.stringify({ urlId: 'lobby-dog', code: 'abc12345' })); expect(connection.getSession()?.token).toBe('jwt-1'); expect(connection.isConnected()).toBe(true); // Fresh token → auth callback resolves without another exchange. const authData = await socket.resolveAuth(); expect(authData.webAppJwt).toBe('jwt-1'); expect(requestBodies).toHaveLength(1); }); it('rejects terminally when the claim code is rejected with 401', async () => { const fakeFetch: WebAppSessionFetch = () => Promise.resolve(new Response('nope', { status: 401 })); const connection = new WebAppConnection({ urlId: 'lobby-dog', sessionBaseUrl: TEST_SESSION_BASE, phyhubUrl: TEST_PHYHUB, code: 'used-code', fetch: fakeFetch, ioFactory: () => { throw new Error('ioFactory should not be called when the mint fails'); }, }); let caught: unknown; try { await connection.connect(); } catch (error) { caught = error; } expect(caught).toBeInstanceOf(WebAppSessionError); expect((caught as WebAppSessionError).terminal).toBe(true); }); it('schedules the foreground refresh timer at deadline - refreshLeadMs', async () => { const refreshLeadMs = 60_000; await connectFixture({ firstExpiresIn: 900, renewalResponder: () => okJson(sessionBody('jwt-2', 'grant-2', 900)), refreshLeadMs, }); expect(scheduledTimers).toHaveLength(1); const expectedDelay = 900 * 1000 - refreshLeadMs; // Allow a generous slop for clock drift between Date.now() reads. expect(Math.abs(scheduledTimers[0]!.delay - expectedDelay)).toBeLessThan(2_000); }); it('clamps the refresh lead to half the token lifetime at phyhub`s 60s TTL floor', async () => { // With TTL == lead (both 60s) the unclamped delay is ~0 — every renewal // would immediately schedule the next one, a tight mint loop. await connectFixture({ firstExpiresIn: 60, renewalResponder: () => okJson(sessionBody('jwt-2', 'grant-2', 60)), refreshLeadMs: 60_000, }); expect(scheduledTimers).toHaveLength(1); const expectedDelay = 60_000 - 30_000; expect(Math.abs(scheduledTimers[0]!.delay - expectedDelay)).toBeLessThan(2_000); }); it('refresh rotates the token AND the stored refresh token', async () => { const { connection, socket, requestBodies } = await connectFixture({ firstExpiresIn: 900, renewalResponder: (callIndex) => okJson(sessionBody(`jwt-${callIndex}`, `grant-${callIndex}`, 900)), }); // Fire the captured foreground refresh timer. expect(scheduledTimers).toHaveLength(1); scheduledTimers[0]!.fn(); await flushAsync(); expect(requestBodies[1]).toBe(JSON.stringify({ urlId: 'lobby-dog', refreshToken: 'grant-1' })); expect(connection.getSession()?.token).toBe('jwt-2'); expect(connection.getSession()?.refreshToken).toBe('grant-2'); // A follow-up refresh was re-armed. expect(scheduledTimers.length).toBe(2); // The next handshake picks up the rotated token without a new exchange. const authData = await socket.resolveAuth(); expect(authData.webAppJwt).toBe('jwt-2'); expect(requestBodies).toHaveLength(2); // And the NEXT renewal uses the rotated grant, not the original one. scheduledTimers[1]!.fn(); await flushAsync(); expect(requestBodies[2]).toBe(JSON.stringify({ urlId: 'lobby-dog', refreshToken: 'grant-2' })); }); it('auth callback renews first when the cached token is stale', async () => { const { connection, socket, requestBodies } = await connectFixture({ firstExpiresIn: 900, renewalResponder: () => okJson(sessionBody('jwt-fresh', 'grant-fresh', 900)), }); // Age the cached session to within the renewal lead, as if the phone // slept through most of the token's lifetime before the handshake. connection.getSession()!.deadlineMs = Date.now() + 1_000; const authData = await socket.resolveAuth(); expect(authData.webAppJwt).toBe('jwt-fresh'); expect(requestBodies[1]).toBe(JSON.stringify({ urlId: 'lobby-dog', refreshToken: 'grant-1' })); }); it('replays registered twin subscriptions after every webAppAuthenticated', async () => { const { connection, socket } = await connectFixture({ firstExpiresIn: 900, renewalResponder: () => okJson(sessionBody('jwt-2', 'grant-2', 900)), }); connection.registerSubscription('twin-camera'); connection.registerSubscription('twin-dog'); expect(socket.emittedToServer).toEqual([ { event: 'twinSubscribe', payload: { twinId: 'twin-camera' } }, { event: 'twinSubscribe', payload: { twinId: 'twin-dog' } }, ]); // Routine reconnect: server re-acks after the token-expiry disconnect. socket.emittedToServer.length = 0; socket.emitFromServer('webAppAuthenticated', { status: 'success' }); expect(socket.emittedToServer).toEqual([ { event: 'twinSubscribe', payload: { twinId: 'twin-camera' } }, { event: 'twinSubscribe', payload: { twinId: 'twin-dog' } }, ]); // Unregistered twins are not replayed (and get an unsubscribe emit). socket.emittedToServer.length = 0; connection.unregisterSubscription('twin-dog'); socket.emitFromServer('webAppAuthenticated', { status: 'success' }); expect(socket.emittedToServer).toEqual([ { event: 'twinUnsubscribe', payload: { twinId: 'twin-dog' } }, { event: 'twinSubscribe', payload: { twinId: 'twin-camera' } }, ]); }); it('stores the web twin from the ack and keeps it across a degraded re-ack', async () => { const webTwin = { id: 'twin-web-endpoint', properties: { desired: { settings: { welcomeText: 'Scan complete' } } }, }; const { connection, socket } = await connectFixture({ firstExpiresIn: 900, renewalResponder: () => okJson(sessionBody('jwt-2', 'grant-2', 900)), }); expect(connection.getWebTwin()).toBeNull(); socket.emitFromServer('webAppAuthenticated', { status: 'success', twin: webTwin }); expect(connection.getWebTwin()).toEqual(webTwin); // A degraded ack (server-side twin lookup failed, twin omitted) must not // wipe the previously delivered twin — settings stay servable. socket.emitFromServer('webAppAuthenticated', { status: 'success' }); expect(connection.getWebTwin()).toEqual(webTwin); }); it('re-dials after the routine server force-disconnect at token expiry', async () => { const { socket } = await connectFixture({ firstExpiresIn: 900, renewalResponder: () => okJson(sessionBody('jwt-2', 'grant-2', 900)), }); // socket.io-client does NOT auto-reconnect on an explicit server // disconnect — the connection must call connect() itself. socket.emitFromServer('disconnect', 'io server disconnect'); expect(socket.connectCalled).toBe(1); // Network drops keep relying on socket.io's built-in reconnection. socket.emitFromServer('disconnect', 'transport close'); expect(socket.connectCalled).toBe(1); }); it('fires sessionTerminated and tears down when renewal fails terminally', async () => { const { connection, socket } = await connectFixture({ firstExpiresIn: 900, renewalResponder: () => new Response('grant revoked', { status: 401 }), }); const terminatedWith: WebAppSessionError[] = []; connection.onSessionTerminated((error) => terminatedWith.push(error)); socket.emitFromServer('connect_error', new Error('jwt expired')); await flushAsync(); await flushAsync(); expect(terminatedWith).toHaveLength(1); expect(terminatedWith[0]!.terminal).toBe(true); expect(terminatedWith[0]!.status).toBe(401); expect(socket.disconnectCalled).toBe(1); expect(connection.getSession()).toBeNull(); expect(connection.getSocket()).toBeNull(); // A terminated connection refuses to reconnect — the QR must be rescanned. let caught: unknown; try { await connection.connect(); } catch (error) { caught = error; } expect((caught as WebAppSessionError).terminal).toBe(true); }); it('backs off with a capped, jittered delay when renewal is rate-limited', async () => { const { connection, socket } = await connectFixture({ firstExpiresIn: 900, renewalResponder: () => new Response('slow down', { status: 429 }), }); const terminatedWith: WebAppSessionError[] = []; connection.onSessionTerminated((error) => terminatedWith.push(error)); const timersBefore = scheduledTimers.length; socket.emitFromServer('connect_error', new Error('jwt expired')); await flushAsync(); await flushAsync(); expect(terminatedWith).toHaveLength(0); expect(socket.disconnectCalled).toBe(0); const retryTimers = scheduledTimers.slice(timersBefore); expect(retryTimers).toHaveLength(1); expect(retryTimers[0]!.delay).toBeGreaterThan(0); expect(retryTimers[0]!.delay).toBeLessThanOrEqual(60_000); }); it('retries in 30s when renewal fails with a retryable non-429 error', async () => { const { socket } = await connectFixture({ firstExpiresIn: 900, renewalResponder: () => new Response('boom', { status: 503 }), }); const timersBefore = scheduledTimers.length; socket.emitFromServer('connect_error', new Error('jwt expired')); await flushAsync(); await flushAsync(); const retryTimers = scheduledTimers.slice(timersBefore); expect(retryTimers).toHaveLength(1); expect(retryTimers[0]!.delay).toBe(30_000); }); it('disconnect() rejects a pending connect() instead of leaving it hanging', async () => { const fakeFetch: WebAppSessionFetch = () => Promise.resolve(okJson(sessionBody('jwt-1', 'grant-1', 900))); let captured: MockWebSocket | null = null; const connection = new WebAppConnection({ urlId: 'lobby-dog', sessionBaseUrl: TEST_SESSION_BASE, phyhubUrl: TEST_PHYHUB, code: 'abc12345', fetch: fakeFetch, // Never emits webAppAuthenticated — the connect() await stays pending. ioFactory: (url, opts) => { captured = new MockWebSocket(url, opts); return captured as unknown as Socket; }, }); const pendingConnect = connection.connect(); let caught: unknown = null; const settled = pendingConnect.catch((error) => { caught = error; }); await flushAsync(); connection.disconnect(); await settled; expect(caught).toBeInstanceOf(WebAppSessionError); expect((caught as WebAppSessionError).message).toContain('disconnected'); }); it('disconnect() tears down the socket and drops the session', async () => { const { connection, socket } = await connectFixture({ firstExpiresIn: 900, renewalResponder: () => okJson(sessionBody('jwt-2', 'grant-2', 900)), }); connection.disconnect(); expect(socket.disconnectCalled).toBe(1); expect(connection.getSession()).toBeNull(); expect(connection.getSocket()).toBeNull(); expect(connection.isConnected()).toBe(false); }); }); // --------------------------------------------------------------------------- // Persistence & resume (TECH-1468) interface MockStorage extends WebSessionStorageLike { dump(): Map; } const buildMockStorage = (initialEntries?: Record): MockStorage => { const entries = new Map(Object.entries(initialEntries ?? {})); return { getItem: (storageKey: string) => entries.get(storageKey) ?? null, setItem: (storageKey: string, value: string) => { entries.set(storageKey, value); }, removeItem: (storageKey: string) => { entries.delete(storageKey); }, dump: () => entries, }; }; const STORAGE_KEY = `${WEB_SESSION_STORAGE_KEY_PREFIX}lobby-dog`; const buildStoredRecord = (refreshToken: string): StoredWebSession => ({ v: STORED_WEB_SESSION_VERSION, refreshToken, sessionDeadlineMs: Date.now() + 3600 * 1000, urlId: 'lobby-dog', savedAt: Date.now(), }); const readRecordFromMock = (storage: MockStorage): StoredWebSession | null => { const raw = storage.dump().get(STORAGE_KEY); return raw ? (JSON.parse(raw) as StoredWebSession) : null; }; interface PersistentFixture { connection: WebAppConnection; socket: MockWebSocket; requestBodies: string[]; storage: MockStorage; } /** * Like connectFixture, but with an injected storage and full control over * every exchange response (mint and renewals alike) and the credential set. */ const connectPersistentFixture = async (options: { code?: string; storedSession?: StoredWebSession; storage?: MockStorage; responder: (callIndex: number) => Response | Promise; }): Promise => { let callCount = 0; const requestBodies: string[] = []; const fakeFetch: WebAppSessionFetch = (_, init) => { callCount += 1; requestBodies.push(init?.body ?? ''); return Promise.resolve(options.responder(callCount)); }; const storage = options.storage ?? buildMockStorage(); let captured: MockWebSocket | null = null; const connection = new WebAppConnection({ urlId: 'lobby-dog', sessionBaseUrl: TEST_SESSION_BASE, phyhubUrl: TEST_PHYHUB, code: options.code, storedSession: options.storedSession, storage, fetch: fakeFetch, ioFactory: (url, opts) => { captured = new MockWebSocket(url, opts); originalSetTimeout(() => captured!.emitFromServer('webAppAuthenticated', { status: 'success' }), 0); return captured as unknown as Socket; }, }); await connection.connect(); return { connection, socket: captured!, requestBodies, storage }; }; describe('WebAppConnection persistence & resume (TECH-1468)', () => { it('requires a code or a stored session', () => { expect( () => new WebAppConnection({ urlId: 'lobby-dog', sessionBaseUrl: TEST_SESSION_BASE, phyhubUrl: TEST_PHYHUB, }), ).toThrow('either code or storedSession required'); }); it('persists the grant on mint and rotates the record on every renewal', async () => { const { requestBodies, storage } = await connectPersistentFixture({ code: 'abc12345', responder: (callIndex) => okJson(sessionBody(`jwt-${callIndex}`, `grant-${callIndex}`, 900, 3600)), }); const afterMint = readRecordFromMock(storage); expect(afterMint?.refreshToken).toBe('grant-1'); expect(afterMint?.urlId).toBe('lobby-dog'); expect(afterMint?.v).toBe(STORED_WEB_SESSION_VERSION); expect(afterMint!.sessionDeadlineMs - Date.now()).toBeGreaterThan(3590 * 1000); // Fire the foreground refresh — the record must rotate with the grant. scheduledTimers[0]!.fn(); await flushAsync(); expect(requestBodies[1]).toBe(JSON.stringify({ urlId: 'lobby-dog', refreshToken: 'grant-1' })); expect(readRecordFromMock(storage)?.refreshToken).toBe('grant-2'); }); it('resumes from a stored session when no code is present', async () => { const { connection, requestBodies, storage } = await connectPersistentFixture({ storedSession: buildStoredRecord('stored-grant'), responder: () => okJson(sessionBody('jwt-resumed', 'grant-rotated', 900, 3000)), }); expect(requestBodies[0]).toBe(JSON.stringify({ urlId: 'lobby-dog', refreshToken: 'stored-grant' })); expect(connection.getSession()?.token).toBe('jwt-resumed'); expect(readRecordFromMock(storage)?.refreshToken).toBe('grant-rotated'); }); it('a fresh code wins over a stored session', async () => { const { requestBodies } = await connectPersistentFixture({ code: 'fresh-scan', storedSession: buildStoredRecord('stored-grant'), responder: () => okJson(sessionBody('jwt-1', 'grant-1', 900, 3600)), }); expect(requestBodies).toHaveLength(1); expect(requestBodies[0]).toBe(JSON.stringify({ urlId: 'lobby-dog', code: 'fresh-scan' })); }); it('falls back to the stored session when the code is terminally rejected', async () => { const { connection, requestBodies } = await connectPersistentFixture({ code: 'expired-screenshot-code', storedSession: buildStoredRecord('stored-grant'), responder: (callIndex) => callIndex === 1 ? new Response('code expired', { status: 401 }) : okJson(sessionBody('jwt-resumed', 'grant-rotated', 900, 3000)), }); expect(requestBodies[0]).toBe(JSON.stringify({ urlId: 'lobby-dog', code: 'expired-screenshot-code' })); expect(requestBodies[1]).toBe(JSON.stringify({ urlId: 'lobby-dog', refreshToken: 'stored-grant' })); expect(connection.getSession()?.token).toBe('jwt-resumed'); }); it('clears the stored record when the resume is terminally rejected', async () => { const storage = buildMockStorage({ [STORAGE_KEY]: JSON.stringify(buildStoredRecord('revoked-grant')), }); const fakeFetch: WebAppSessionFetch = () => Promise.resolve(new Response('revoked', { status: 401 })); const connection = new WebAppConnection({ urlId: 'lobby-dog', sessionBaseUrl: TEST_SESSION_BASE, phyhubUrl: TEST_PHYHUB, storedSession: buildStoredRecord('revoked-grant'), storage, fetch: fakeFetch, ioFactory: () => { throw new Error('ioFactory should not be called when the resume fails'); }, }); let caught: unknown; try { await connection.connect(); } catch (error) { caught = error; } expect((caught as WebAppSessionError).terminal).toBe(true); expect(storage.dump().has(STORAGE_KEY)).toBe(false); }); it('clears the stored record on terminal renewal (session revoked mid-flight)', async () => { const { connection, storage } = await connectPersistentFixture({ code: 'abc12345', responder: (callIndex) => callIndex === 1 ? okJson(sessionBody('jwt-1', 'grant-1', 900, 3600)) : new Response('grant revoked', { status: 401 }), }); expect(readRecordFromMock(storage)?.refreshToken).toBe('grant-1'); const terminatedWith: WebAppSessionError[] = []; connection.onSessionTerminated((error) => terminatedWith.push(error)); scheduledTimers[0]!.fn(); await flushAsync(); await flushAsync(); expect(terminatedWith).toHaveLength(1); expect(storage.dump().has(STORAGE_KEY)).toBe(false); }); it('clears the record instead of keeping a stale one when the storage write fails', async () => { const storage = buildMockStorage({ [STORAGE_KEY]: JSON.stringify(buildStoredRecord('previous-grant')), }); storage.setItem = () => { throw new Error('QuotaExceededError'); }; const { connection } = await connectPersistentFixture({ code: 'abc12345', storage, responder: () => okJson(sessionBody('jwt-1', 'grant-1', 900, 3600)), }); // The session works (memory-only) but no stored record survives — a stale // one would replay a consumed grant and trip reuse detection. expect(connection.isConnected()).toBe(true); expect(storage.dump().has(STORAGE_KEY)).toBe(false); }); it('adopts a grant rotated by another tab before renewing', async () => { const { requestBodies, storage } = await connectPersistentFixture({ code: 'abc12345', responder: (callIndex) => okJson(sessionBody(`jwt-${callIndex}`, `grant-${callIndex}`, 900, 3600)), }); expect(readRecordFromMock(storage)?.refreshToken).toBe('grant-1'); // Another tab rotated after us and wrote its newer grant. storage.dump().set(STORAGE_KEY, JSON.stringify(buildStoredRecord('other-tab-grant'))); scheduledTimers[0]!.fn(); await flushAsync(); expect(requestBodies[1]).toBe(JSON.stringify({ urlId: 'lobby-dog', refreshToken: 'other-tab-grant' })); }); });