Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions verify/harness/api/stepUp.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
73 changes: 73 additions & 0 deletions verify/harness/lib/matrixReporter.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<string, Entry>();

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<string, boolean>();
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'));
}
}
1 change: 1 addition & 0 deletions verify/harness/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }],
],
Expand Down