import { execSync } from "node:child_process"; import { expect, type Page } from "@playwright/test"; /** Dev-system credentials — these exist in the planarchy.dev seed data */ export const DEV_USERS = { admin: { email: "admin@planarchy.dev", password: "admin123" }, manager: { email: "manager@planarchy.dev", password: "manager123" }, viewer: { email: "viewer@planarchy.dev", password: "viewer123" }, } as const; export async function signIn(page: Page, email: string, password: string) { await page.goto("/auth/signin"); await page.fill('input[type="email"]', email); await page.fill('input[type="password"]', password); await page.click('button[type="submit"]'); await expect(page).toHaveURL(/\/(dashboard|resources)/, { timeout: 15000 }); } export async function signOut(page: Page) { // next-auth/react signOut() POSTs to /auth/signout with a CSRF token. // There is no GET-accessible signout page in this app (/auth/signout returns 404). // Replicate what the client-side signOut() function does: // 1. Fetch the CSRF token from /auth/csrf // 2. POST to /auth/signout with that token // 3. Follow the redirect to /auth/signin await page.goto("/dashboard"); // land on any authenticated page for cookie context await page.evaluate(async () => { const csrfRes = await fetch("/api/auth/csrf"); const { csrfToken } = await csrfRes.json() as { csrfToken: string }; await fetch("/api/auth/signout", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ csrfToken, callbackUrl: "/auth/signin", json: "true" }), redirect: "follow", }); }); // After the POST clears the session cookie, navigating to a protected route // should redirect to sign-in. await page.goto("/dashboard"); await page.waitForURL(/\/auth\/signin/, { timeout: 10000 }); } // ── Mailhog helpers ──────────────────────────────────────────────────────────── const MAILHOG_API = process.env["MAILHOG_API"] ?? "http://localhost:8025"; type MailhogMessage = { Content: { Headers: { Subject?: string[]; To?: string[]; "Content-Transfer-Encoding"?: string[] }; Body: string; }; MIME: { Parts?: Array<{ Headers: { "Content-Type"?: string[]; "Content-Transfer-Encoding"?: string[] }; Body: string; }>; } | null; }; /** Decode a MIME part body based on its Content-Transfer-Encoding header value. */ function decodeMimeBody(body: string, encoding: string | undefined): string { const enc = (encoding ?? "").toLowerCase().trim(); if (enc === "quoted-printable") { return body .replace(/=\r\n/g, "") // soft line break (CRLF) .replace(/=\n/g, "") // soft line break (LF) .replace(/=([0-9A-Fa-f]{2})/g, (_, hex: string) => String.fromCharCode(parseInt(hex, 16)), ); } if (enc === "base64") { return Buffer.from(body.replace(/\s/g, ""), "base64").toString("utf8"); } return body; } type MailhogResponse = { count: number; items: MailhogMessage[]; }; /** Delete all messages in Mailhog (call in beforeEach to prevent cross-test contamination). */ export async function clearMailhog(): Promise { await fetch(`${MAILHOG_API}/api/v1/messages`, { method: "DELETE" }); } /** * Poll Mailhog until a message to `address` appears. Returns the message. * Throws after `timeoutMs` if no matching message is found. */ export async function getLatestEmailTo( address: string, { timeoutMs = 10_000, pollIntervalMs = 500 }: { timeoutMs?: number; pollIntervalMs?: number } = {}, ): Promise<{ subject: string; body: string; html: string }> { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const res = await fetch(`${MAILHOG_API}/api/v2/messages?limit=50`); if (res.ok) { const data = (await res.json()) as MailhogResponse; const match = data.items.find((msg) => { const to = msg.Content.Headers.To ?? []; return to.some((t) => t.toLowerCase().includes(address.toLowerCase())); }); if (match) { const subject = (match.Content.Headers.Subject ?? [])[0] ?? ""; // Decode body parts based on Content-Transfer-Encoding const htmlPart = match.MIME?.Parts?.find((p) => (p.Headers["Content-Type"]?.[0] ?? "").includes("text/html"), ); const textPart = match.MIME?.Parts?.find((p) => (p.Headers["Content-Type"]?.[0] ?? "").includes("text/plain"), ); const rootEnc = match.Content.Headers["Content-Transfer-Encoding"]?.[0]; const htmlEnc = htmlPart?.Headers["Content-Transfer-Encoding"]?.[0]; const textEnc = textPart?.Headers["Content-Transfer-Encoding"]?.[0]; const html = htmlPart ? decodeMimeBody(htmlPart.Body, htmlEnc) : decodeMimeBody(match.Content.Body, rootEnc); const body = textPart ? decodeMimeBody(textPart.Body, textEnc) : decodeMimeBody(match.Content.Body, rootEnc); return { subject, body, html }; } } await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); } throw new Error(`No email to "${address}" found within ${timeoutMs}ms`); } /** * Extract a URL from email body/html matching a path prefix. * e.g. extractUrlFromEmail(email, "/invite/") → "http://localhost:3100/invite/abc123" */ export function extractUrlFromEmail( email: { body: string; html: string }, pathPrefix: string, ): string { const text = email.html || email.body; const match = text.match(new RegExp(`https?://[^\\s"'<>]*${pathPrefix.replace("/", "\\/")}[^\\s"'<>]*`)); if (!match?.[0]) { throw new Error(`No URL with prefix "${pathPrefix}" found in email`); } return match[0]; } /** * Reset a user's password directly via the DB (no tRPC roundtrip needed). * Computes an argon2id hash in Node.js, updates password_hash via docker exec psql. * Use in afterEach teardown — not in the test flow itself. */ export async function resetPasswordViaApi( _baseURL: string, email: string, newPassword: string, ): Promise { const { hash } = await import("@node-rs/argon2"); const passwordHash = await hash(newPassword); // argon2id hashes use base64 chars only — safe inside a SQL single-quoted string // Column name is camelCase (Prisma default) — must be double-quoted in SQL const sql = `UPDATE users SET "passwordHash" = '${passwordHash}' WHERE email = '${email}';`; execSync( `docker exec -i capakraken-postgres-1 psql -U capakraken -d capakraken`, { input: sql, encoding: "utf8" }, ); } // ── tRPC helpers ─────────────────────────────────────────────────────────────── /** * Intercept all tRPC batch responses and assert none return HTTP 401. * Returns a list of intercepted tRPC paths that were called. */ export async function assertNoTrpc401s(page: Page, action: () => Promise) { const failures: string[] = []; page.on("response", (response) => { if (response.url().includes("/api/trpc/") && response.status() === 401) { const url = new URL(response.url()); failures.push(url.pathname + url.search.slice(0, 80)); } }); await action(); if (failures.length > 0) { throw new Error( `tRPC 401 responses detected (session registry / auth broken):\n${failures.join("\n")}`, ); } }