diff --git a/verify/harness/api/stepUp.spec.ts b/verify/harness/api/stepUp.spec.ts new file mode 100644 index 0000000..ba02fb2 --- /dev/null +++ b/verify/harness/api/stepUp.spec.ts @@ -0,0 +1,39 @@ +import { expect, test } from '../lib/fixtures'; +import { registerAndVerifyEmail } from '../lib/flows'; +import { totp } from '../lib/totp'; + +test.describe('step-up (api)', () => { + test('TOTP MFA freshens the session step-up status', async ({ actor }) => { + const { token } = await registerAndVerifyEmail(actor.ctx, actor.email); + const auth = { Authorization: `Bearer ${token}` }; + + // Enroll TOTP (enrollment alone must not satisfy step-up). + const start = await actor.ctx.post('/totp/enroll/start', { headers: auth, data: {} }); + expect(start.ok(), `enroll/start -> ${start.status()}`).toBeTruthy(); + const { secret } = await start.json(); + const enroll = await actor.ctx.post('/totp/enroll/verify', { + headers: auth, + data: { code: totp(secret) }, + }); + expect(enroll.ok(), `enroll/verify -> ${enroll.status()}`).toBeTruthy(); + + const before = await actor.ctx.get('/step-up/status', { headers: auth }); + expect(before.ok()).toBeTruthy(); + expect((await before.json()).fresh, 'step-up is not fresh before MFA').toBe(false); + + // Verifying TOTP MFA records a step-up. Use the next window's code: the API + // rejects a code whose counter is not strictly greater than the last used one + // (enrollment just consumed the current window), and accepts up to +1 of skew. + const mfa = await actor.ctx.post('/totp/verify-mfa', { + headers: auth, + data: { code: totp(secret, Date.now() + 30_000) }, + }); + expect(mfa.ok(), `verify-mfa -> ${mfa.status()} ${await mfa.text()}`).toBeTruthy(); + const mfaBody = await mfa.json(); + expect(mfaBody.fresh, 'verify-mfa returns a fresh step-up').toBe(true); + expect(mfaBody.method, 'step-up method is totp').toBe('totp'); + + const after = await actor.ctx.get('/step-up/status', { headers: auth }); + expect((await after.json()).fresh, 'step-up is fresh after MFA').toBe(true); + }); +}); diff --git a/verify/harness/lib/matrixReporter.ts b/verify/harness/lib/matrixReporter.ts new file mode 100644 index 0000000..6abc0d5 --- /dev/null +++ b/verify/harness/lib/matrixReporter.ts @@ -0,0 +1,73 @@ +import type { FullResult, Reporter, TestCase, TestResult } from '@playwright/test/reporter'; + +// Prints a flow x layer conformance grid at the end of the run. Layer comes from +// the spec's directory (api/adapter/react); flow from the spec file name, with a +// few aliases folded together so the same flow lines up across layers. + +const LAYERS = ['api', 'adapter', 'react'] as const; +type Layer = (typeof LAYERS)[number]; + +const FLOW_ALIASES: Record = { + emailOtpLogin: 'emailOtp', + registration: 'register', + magicLinkLogin: 'magicLink', + passkeyRegister: 'passkey', + passkeyLogin: 'passkey', + adminBootstrap: 'admin', + sessionLifecycle: 'session', +}; + +interface Entry { + flow: string; + layer: Layer; + passed: boolean; +} + +export default class MatrixReporter implements Reporter { + private tests = new Map(); + + onTestEnd(test: TestCase, result: TestResult): void { + const match = test.location.file.match(/\/(api|adapter|react)\/([^/]+)\.spec\.[tj]s$/); + if (!match) return; + const layer = match[1] as Layer; + const base = match[2]; + const flow = FLOW_ALIASES[base] ?? base; + // Keyed by test id so the final attempt (after retries) is the one that counts. + this.tests.set(test.id, { flow, layer, passed: result.status === 'passed' }); + } + + onEnd(_result: FullResult): void { + const cells = new Map(); + for (const { flow, layer, passed } of this.tests.values()) { + const key = `${flow}|${layer}`; + cells.set(key, (cells.get(key) ?? true) && passed); + } + if (cells.size === 0) return; + + const flows = [...new Set([...cells.keys()].map((k) => k.split('|')[0]))].sort(); + const symbol = (flow: string, layer: Layer): string => { + const value = cells.get(`${flow}|${layer}`); + return value === undefined ? '-' : value ? '✓' : '✗'; + }; + + const flowWidth = Math.max('flow'.length, ...flows.map((f) => f.length)); + const pad = (text: string, width: number) => + text + ' '.repeat(Math.max(0, width - text.length)); + const row = (label: string, get: (layer: Layer) => string) => + ` ${pad(label, flowWidth)} ${LAYERS.map((l) => pad(get(l), 9)).join('')}`; + const rule = ` ${'-'.repeat(flowWidth + 3 + LAYERS.length * 9)}`; + + const lines = [ + '', + ' Conformance matrix', + rule, + row('flow', (l) => l), + rule, + ...flows.map((f) => row(f, (l) => symbol(f, l))), + rule, + '', + ]; + // eslint-disable-next-line no-console + console.log(lines.join('\n')); + } +} diff --git a/verify/harness/playwright.config.ts b/verify/harness/playwright.config.ts index 88e2d4f..47b8c8d 100644 --- a/verify/harness/playwright.config.ts +++ b/verify/harness/playwright.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ workers: 1, reporter: [ ['list'], + ['./lib/matrixReporter.ts'], ['junit', { outputFile: 'results/junit.xml' }], ['html', { outputFolder: 'results/html', open: 'never' }], ],