deps-upgrade(qr-device-login): ⬆️ Update integration tests to handle QR device login dependency changes
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
9dab987d82
commit
29a9ec9dd6
1 changed files with 223 additions and 0 deletions
223
qr-device-login/server/test/integration.test.ts
Normal file
223
qr-device-login/server/test/integration.test.ts
Normal file
|
|
@ -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<string, string> ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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<CreateSessionResponse> => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue