dorfteich/apps/web/e2e/helpers.ts
Claude Fable 5 d32c8c3730
Some checks failed
CI / Lint, typecheck, test (pull_request) Failing after 1m51s
CI / Auth e2e pack (pull_request) Has been skipped
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Build container images (pull_request) Has been skipped
#189: make the CSRF origin check fail closed
A cookie-carrying mutation without Origin and Referer (or with an
unparsable one) is now rejected with 403 csrf_origin_mismatch instead
of passing unchecked. The exception for non-browser clients stays
structural: PAT/bearer requests carry no session cookie and never reach
the check, and a request that does carry the cookie is always checked.

The test harness injects the matching Origin (supertest simulates a
browser page of this instance) with an explicit suppression header for
the negative cases; the Playwright fixture contexts send the header on
their manual seeding calls; release-qa.sh pins APP_BASE_URL and sends
the matching Origin. Dedicated spec covers: missing headers 403,
mismatch 403, unparsable 403, match passes, GETs untouched, PAT
mutation without headers passes, cookie+bearer still checked.

Refs #189

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 09:34:44 +02:00

65 lines
2.3 KiB
TypeScript

import { type Browser, type BrowserContext, request } from '@playwright/test';
export const FIXTURE_PASSWORD = 'fixture passwort 123';
/**
* Signs a fixture user in through the api and returns a browser context
* that already carries the session cookie — UI tests skip the login form
* unless the form itself is under test.
*/
export async function contextForUser(
browser: Browser,
baseURL: string,
username: string,
password: string = FIXTURE_PASSWORD,
): Promise<BrowserContext> {
const api = await request.newContext({ baseURL });
const response = await api.post('/api/v1/auth/login', {
data: { usernameOrEmail: username, password },
});
if (!response.ok()) {
throw new Error(`fixture login for ${username} failed: ${response.status()}`);
}
const storageState = await api.storageState();
await api.dispose();
// The CSRF check fails closed (#189): cookie mutations must carry a
// matching Origin. The browser sends it on its own fetches; this default
// covers the specs' manual `context.request.*` seeding calls too.
return browser.newContext({
baseURL,
storageState,
extraHTTPHeaders: { origin: new URL(baseURL).origin },
});
}
/** Latest mail for an address from the Mailpit REST api. */
export async function latestMailFor(
mailpitUrl: string,
address: string,
): Promise<{ subject: string; text: string }> {
const api = await request.newContext({ baseURL: mailpitUrl });
for (let attempt = 0; attempt < 30; attempt += 1) {
const list = await api.get('/api/v1/search', {
params: { query: `to:${address}`, limit: 1 },
});
const body = (await list.json()) as { messages?: { ID: string; Subject: string }[] };
const found = body.messages?.[0];
if (found) {
const message = await api.get(`/api/v1/message/${found.ID}`);
const details = (await message.json()) as { Text: string };
await api.dispose();
return { subject: found.Subject, text: details.Text };
}
// The outbox worker delivers every 15s — poll until it does.
await new Promise((resolve) => setTimeout(resolve, 2000));
}
await api.dispose();
throw new Error(`no mail arrived for ${address}`);
}
export function tokenFromMail(text: string): string {
const match = text.match(/token=([A-Za-z0-9_-]+)/);
if (!match) throw new Error('mail contains no token link');
return match[1]!;
}