/** * AI Software Factory — Playwright global setup. * * Ensures an authenticated demo user exists and writes a Playwright * storageState file (.auth/user.json) that specs reuse for every test. * Steps: * * 1. If .auth/user.json exists, verify it still works by calling * GET /api/v1/auth/me with a Bearer context built from its stored * access token (NOT a cookie — see the concurrency note below); * keep it on 200. * 2. Otherwise log in as demo@factory.local (the seeded demo account). * 3. If that fails (no seed yet), register a throwaway user with a random * suffix email and log in. * * Auth model (security-architecture.md §AuthN, S10): email+password → JWT * access token (Bearer; JS memory on the web client, header on API contexts) * + a rotating opaque refresh token in an HttpOnly `factory_refresh` cookie. * We capture BOTH surfaces into the storage state: the cookie (browser flows * silent-refresh on load) and the access token (the Bearer `api` fixture + * the no-rotation liveness check below). * * CONCURRENCY NOTE: the file is consumed by single-context specs * (collab-two-tab); the shared `api`/`authPage` fixtures perform a FRESH * login per test instead of reusing this file's cookie — parallel contexts * refreshing the SAME refresh cookie would trip reuse-detection and revoke * the whole session family (see fixtures.ts header). Reusing the file's * access token for `GET /me` is safe: /me does not rotate anything. */ import { request as pwRequest } from "@playwright/test"; import * as fs from "node:fs"; import * as path from "node:path"; const API_BASE = process.env.E2E_API_URL ?? "http://localhost:4000"; const AUTH_DIR = path.join(__dirname, ".auth"); const AUTH_FILE = path.join(AUTH_DIR, "user.json"); // Demo credentials from prisma/seed.ts (Phase 0 seeding). Overridable for // environments where the seed user differs. const DEMO_EMAIL = process.env.E2E_DEMO_EMAIL ?? "demo@factory.local"; const DEMO_PASSWORD = process.env.E2E_DEMO_PASSWORD ?? "Factory!2345"; interface StorageState { /** Access token captured at login (Bearer surface; memory-only on web). */ accessToken: string; cookies: { name: string; value: string; domain: string; path: string; expires: number; httpOnly: boolean; secure: boolean; sameSite: "Strict" | "Lax" | "None"; }[]; origins: { origin: string; localStorage: { name: string; value: string }[]; }[]; } interface SetCookieParsed { name: string; value: string; path: string; httpOnly: boolean; secure: boolean; sameSite: "Strict" | "Lax" | "None"; } /** * Minimal Set-Cookie parser (a single header value). Handles the shape the * API emits: `factory_refresh=rft_…; Path=/; HttpOnly; SameSite=Lax[; Secure]`. * We only need the attributes Playwright's storageState consumes; unknown * attributes (Max-Age, Domain, Expires) are ignored — the refresh cookie is a * session cookie for our purposes (expires -1). */ function parseSetCookie(header: string): SetCookieParsed[] { // Multiple Set-Cookie lines arrive as one comma-joined value in the headers // map; the login sets a single cookie, so split defensively on commas that // are NOT inside the value (cookie values are base64url — no commas), so a // plain split on ", " is safe for this contract. const parts = header.split(/,(?=[^;]*=)/); return parts.map((raw) => { const firstSemi = raw.indexOf(";"); const pair = firstSemi === -1 ? raw : raw.slice(0, firstSemi); const eq = pair.indexOf("="); const name = eq === -1 ? pair.trim() : pair.slice(0, eq).trim(); const value = eq === -1 ? "" : pair.slice(eq + 1).trim(); const attrs = firstSemi === -1 ? "" : raw.slice(firstSemi + 1); const lower = attrs.toLowerCase(); return { name, value, path: /path=([^;]+)/i.exec(attrs)?.[1] ?? "/", httpOnly: /httponly/i.test(lower), secure: /secure/i.test(lower), sameSite: (/samesite=strict/i.test(lower) ? "Strict" : /samesite=none/i.test(lower) ? "None" : "Lax") as "Strict" | "Lax" | "None", }; }); } async function apiReachable(): Promise { try { const req = await pwRequest.newContext({ baseURL: API_BASE, timeout: 5_000, }); // Any HTTP response (even 401/404) proves the API is up. const res = await req.get("/api/v1/health", { timeout: 5_000 }).catch(() => null); await req.dispose(); return res !== null; } catch { return false; } } /** * Perform login (or register-then-login) against the API and assemble a * storageState from the login's Set-Cookie + the access token in the body. * The access token is stored for the Bearer `api` fixture + the liveness * check — NOT mirrored into any localStorage (the web client keeps the token * in JS memory; the localStorage mirror was the stale Phase-1 contract, S10). */ async function loginAndCapture( email: string, password: string, registerFirst = false, ): Promise { const req = await pwRequest.newContext({ baseURL: API_BASE }); try { if (registerFirst) { const reg = await req.post("/api/v1/auth/register", { data: { email, password, name: "E2E Demo" }, }); if (![200, 201].includes(reg.status())) { console.error(`[global-setup] register failed: ${reg.status()} ${await reg.text()}`); return null; } } const login = await req.post("/api/v1/auth/login", { data: { email, password }, }); if (login.status() !== 200) { console.error(`[global-setup] login failed: ${login.status()} ${await login.text()}`); return null; } // Parse the login's Set-Cookie header instead of calling req.cookies(): // `APIRequestContext` has NO cookies() method on the pinned // @playwright/test (1.49.x — verified: the resolved instance methods are // dispose/delete/head/get/patch/post/put/fetch/_innerFetch/storageState), // so the committed cookie-jar version crashed global-setup and aborted the // ENTIRE e2e suite in CI (TASK-0094 slice 7b — recorded + fixed). // The login sets exactly one cookie: `factory_refresh` (HttpOnly, // SameSite=Lax, Secure in prod). domain "" makes Playwright treat it as a // session cookie for the origin; expires -1 = session. const setCookie: string | undefined = login.headers()["set-cookie"]; const cookies: StorageState["cookies"] = (setCookie ? parseSetCookie(setCookie) : [] ).map((c) => ({ name: c.name, value: c.value, domain: "", path: c.path, expires: -1, httpOnly: c.httpOnly, secure: c.secure, sameSite: c.sameSite, })); // The access token (Bearer surface) — /auth/me does not rotate anything, // so reusing it for the liveness check is rotation-safe. let accessToken = ""; try { const body = (await login.json()) as { accessToken?: string }; accessToken = body.accessToken ?? ""; } catch { // Non-JSON login body — cookie-only flow; the browser side still works. } return { accessToken, cookies, origins: [] }; } finally { await req.dispose(); } } /** * Verify an existing storageState file still authenticates (200 on /me) * WITHOUT touching the refresh cookie: a cookie-driven refresh here would * ROTATE the shared token and, on the next spec's use of the file, trip * reuse-detection. The Bearer /me read is the rotation-safe liveness probe * (it also matches how the `api` fixture authenticates). */ async function stateIsAlive(state: StorageState): Promise { const token = state.accessToken; if (!token) return false; const req = await pwRequest.newContext({ baseURL: API_BASE, timeout: 5_000, extraHTTPHeaders: { Authorization: `Bearer ${token}` }, }); try { const res = await req.get("/api/v1/auth/me"); return res.status() === 200; } catch { return false; } finally { await req.dispose(); } } export default async function globalSetup(): Promise { const apiUp = await apiReachable(); if (!apiUp) { throw new Error( `[global-setup] API is not reachable at ${API_BASE}.` + " Start the stack first (pnpm docker:up && pnpm dev), or set E2E_API_URL.", ); } // 1) Reuse a valid cached state (Bearer /me — no rotation). if (fs.existsSync(AUTH_FILE)) { try { const cached: StorageState = JSON.parse(fs.readFileSync(AUTH_FILE, "utf8")); if (await stateIsAlive(cached)) { console.log("[global-setup] reusing existing auth state (.auth/user.json)"); return; } console.log("[global-setup] cached auth state expired — re-authenticating"); } catch { console.log("[global-setup] cached auth state unreadable — re-authenticating"); } } // 2) Seeded demo user. let state = await loginAndCapture(DEMO_EMAIL, DEMO_PASSWORD); // 3) Fall back to a fresh user (unique email per run, so repeated runs // without a seed do not collide). if (!state) { const email = `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@factory.local`; console.log(`[global-setup] demo login failed — registering ${email}`); state = await loginAndCapture(email, DEMO_PASSWORD, /* registerFirst */ true); } if (!state) { throw new Error( "[global-setup] could not authenticate (demo login and fresh register both failed).", ); } fs.mkdirSync(AUTH_DIR, { recursive: true }); fs.writeFileSync(AUTH_FILE, JSON.stringify(state, null, 2)); console.log(`[global-setup] auth state written to ${AUTH_FILE}`); }