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
65 lines
2.3 KiB
TypeScript
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]!;
|
|
}
|