From 29a9ec9dd6cbe4fa19939c44b4b3c9587b2a3a2c Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 9 Apr 2026 12:07:51 -0700 Subject: [PATCH] =?UTF-8?q?deps-upgrade(qr-device-login):=20=E2=AC=86?= =?UTF-8?q?=EF=B8=8F=20Update=20integration=20tests=20to=20handle=20QR=20d?= =?UTF-8?q?evice=20login=20dependency=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../server/test/integration.test.ts | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 qr-device-login/server/test/integration.test.ts diff --git a/qr-device-login/server/test/integration.test.ts b/qr-device-login/server/test/integration.test.ts new file mode 100644 index 0000000..ee210fb --- /dev/null +++ b/qr-device-login/server/test/integration.test.ts @@ -0,0 +1,223 @@ +/** + * Integration test — drives the full lifecycle through the router using + * direct Request/Response, simulating both the admin (mTLS) and public + * (phone scan) sides. Verifies the actual contract the client SDK depends on. + * + * This is NOT a unit test of the store or routes — those have their own + * suites. This test proves the pieces compose correctly end-to-end. + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import type { + CreateSessionResponse, + ExchangeCodeResponse, + PollSessionResponse, +} from '@lilith/qr-device-login-protocol'; +import { ConsumerRegistry } from '../src/consumers'; +import { createRouter, type Router } from '../src/router'; +import { SessionStore } from '../src/store'; + +function buildRouter(): { route: Router; store: SessionStore } { + const store = new SessionStore({ maxPendingTokens: 50, tokenTtlMs: 60_000 }); + const route = createRouter({ + config: { + port: 0, + publicBaseUrl: 'https://qr-auth.integration-test', + consumersPath: '(test)', + maxPendingTokens: 50, + tokenTtlMs: 60_000, + trustedProxyIps: ['127.0.0.1'], + }, + registry: new ConsumerRegistry([ + { + id: 'test-consumer', + cn: 'test.consumer.local', + allowedCallbacks: Object.freeze(['https://app.test/callback']), + }, + ]), + store, + }); + return { route, store }; +} + +function adminReq(path: string, init?: RequestInit): Request { + return new Request(`http://localhost${path}`, { + ...init, + headers: { + 'Content-Type': 'application/json', + 'X-Client-CN': 'test.consumer.local', + ...(init?.headers as Record ?? {}), + }, + }); +} + +describe('full lifecycle integration', () => { + let store: SessionStore | null = null; + + afterEach(() => { + store?.stop(); + store = null; + }); + + it('create → poll(pending) → scan → poll(scanned) → exchange → burned', async () => { + const { route, store: s } = buildRouter(); + store = s; + + // 1. CREATE + const createRes = await route( + adminReq('/admin/v1/sessions', { + method: 'POST', + body: JSON.stringify({ + callbackUrl: 'https://app.test/callback', + metadata: { userId: 'u-42', role: 'admin' }, + }), + }), + '127.0.0.1', + ); + expect(createRes.status).toBe(201); + const created: CreateSessionResponse = await createRes.json(); + expect(created.id).toMatch(/^[a-f0-9-]{36}$/); + expect(created.qrDataUrl).toMatch(/^data:image\/png;base64,/); + expect(created.scanUrl).toMatch(/^https:\/\/qr-auth\.integration-test\/scan\/[a-f0-9]{64}$/); + expect(Date.parse(created.expiresAt)).toBeGreaterThan(Date.now()); + + // 2. POLL — pending + const poll1 = await route( + adminReq(`/admin/v1/sessions/${created.id}`), + '127.0.0.1', + ); + expect(poll1.status).toBe(200); + const poll1Body: PollSessionResponse = await poll1.json(); + expect(poll1Body.status).toBe('pending'); + + // 3. SCAN — phone hits public endpoint + const token = created.scanUrl.split('/scan/')[1]!; + const scanRes = await route( + new Request(`http://localhost/scan/${token}`), + '203.0.113.42', // random phone IP — no mTLS needed + ); + expect(scanRes.status).toBe(302); + const location = new URL(scanRes.headers.get('Location')!); + expect(location.origin + location.pathname).toBe('https://app.test/callback'); + const callbackId = location.searchParams.get('id')!; + const callbackCode = location.searchParams.get('code')!; + expect(callbackId).toBe(created.id); + expect(callbackCode).toMatch(/^[a-f0-9]{64}$/); + + // 4. POLL — scanned + const poll2 = await route( + adminReq(`/admin/v1/sessions/${created.id}`), + '127.0.0.1', + ); + const poll2Body: PollSessionResponse = await poll2.json(); + expect(poll2Body.status).toBe('scanned'); + + // 5. EXCHANGE — consumer backend confirms the scan + const exchangeRes = await route( + adminReq(`/admin/v1/sessions/${created.id}/exchange`, { + method: 'POST', + body: JSON.stringify({ code: callbackCode }), + }), + '127.0.0.1', + ); + expect(exchangeRes.status).toBe(200); + const exchangeBody: ExchangeCodeResponse = await exchangeRes.json(); + expect(exchangeBody.metadata).toEqual({ userId: 'u-42', role: 'admin' }); + expect(Date.parse(exchangeBody.scannedAt)).toBeGreaterThan(0); + + // 6. BURNED — everything returns not-found/expired now + const pollBurned = await route( + adminReq(`/admin/v1/sessions/${created.id}`), + '127.0.0.1', + ); + const pollBurnedBody: PollSessionResponse = await pollBurned.json(); + expect(pollBurnedBody.status).toBe('expired'); + + const reScan = await route( + new Request(`http://localhost/scan/${token}`), + '203.0.113.42', + ); + expect(reScan.status).toBe(400); + expect(await reScan.text()).toContain('QR Code Expired'); + + const reExchange = await route( + adminReq(`/admin/v1/sessions/${created.id}/exchange`, { + method: 'POST', + body: JSON.stringify({ code: callbackCode }), + }), + '127.0.0.1', + ); + expect(reExchange.status).toBe(404); + }); + + it('concurrent sessions from same consumer are independent', async () => { + const { route, store: s } = buildRouter(); + store = s; + + const makeSession = async (): Promise => { + const res = await route( + adminReq('/admin/v1/sessions', { + method: 'POST', + body: JSON.stringify({ callbackUrl: 'https://app.test/callback' }), + }), + '127.0.0.1', + ); + return res.json(); + }; + + const [s1, s2] = await Promise.all([makeSession(), makeSession()]); + expect(s1.id).not.toBe(s2.id); + + // Scan only s1 + const token1 = s1.scanUrl.split('/scan/')[1]!; + await route(new Request(`http://localhost/scan/${token1}`), '1.2.3.4'); + + // s1 is scanned, s2 still pending + const p1 = await route(adminReq(`/admin/v1/sessions/${s1.id}`), '127.0.0.1'); + const p2 = await route(adminReq(`/admin/v1/sessions/${s2.id}`), '127.0.0.1'); + expect((await p1.json() as PollSessionResponse).status).toBe('scanned'); + expect((await p2.json() as PollSessionResponse).status).toBe('pending'); + }); + + it('rejects scan after TTL expiry', async () => { + // Use a 1ms TTL store + const shortStore = new SessionStore({ maxPendingTokens: 10, tokenTtlMs: 1 }); + const route = createRouter({ + config: { + port: 0, + publicBaseUrl: 'https://qr-auth.integration-test', + consumersPath: '(test)', + maxPendingTokens: 10, + tokenTtlMs: 1, + trustedProxyIps: ['127.0.0.1'], + }, + registry: new ConsumerRegistry([ + { + id: 'test-consumer', + cn: 'test.consumer.local', + allowedCallbacks: Object.freeze(['https://app.test/callback']), + }, + ]), + store: shortStore, + }); + store = shortStore; + + const createRes = await route( + adminReq('/admin/v1/sessions', { + method: 'POST', + body: JSON.stringify({ callbackUrl: 'https://app.test/callback' }), + }), + '127.0.0.1', + ); + const created: CreateSessionResponse = await createRes.json(); + const token = created.scanUrl.split('/scan/')[1]!; + + // Wait past TTL + const deadline = Date.now() + 5; + while (Date.now() < deadline) { /* spin */ } + + const scanRes = await route(new Request(`http://localhost/scan/${token}`), '1.2.3.4'); + expect(scanRes.status).toBe(400); + expect(await scanRes.text()).toContain('expired'); + }); +});