/** * CSRF Flow Integration Suite * * End-to-end double-submit CSRF check against a real handler: a logged-in user * exists (fixture account + session row), and `POST /api/auth/logout` is driven * with and without the matching `sr_csrf` cookie / `x-csrf-token` header. * * Run with: npx tsx tests/integration/csrf_flow.test.ts * * Requires a reachable DATABASE_URL with the migrations applied * (`docker compose up -d postgres && npm run db:push`). Without a database the * script reports a skip and exits 0 rather than pretending to have passed. */ // Keep first: populates DATABASE_URL before the database module below reads it. import "./loadEnv"; import { eq, like } from "drizzle-orm"; import { NextRequest } from "next/server"; import { describe, test, expect, beforeAll, runAllTests } from "../e2e/runner"; import { db, isDatabaseAvailable, pool } from "../../src/lib/db"; import { sessions, users } from "../../src/lib/schema/db"; import { createUser } from "../../src/lib/auth/accounts"; import { hashPassword } from "../../src/lib/auth/password"; import { issueToken } from "../../src/lib/auth/tokens"; import { POST as logoutPOST } from "../../src/app/api/auth/logout/route"; import { CSRF_COOKIE, CSRF_HEADER, issueCsrfToken, } from "../../src/lib/auth/csrf"; /** Namespaced so cleanup can never touch a real account. */ const RUN_ID = Date.now().toString(36); const LOCAL_PART = `authtest-csrf-${RUN_ID}`; const BASE_EMAIL = `${LOCAL_PART}@gmail.com`; async function cleanup() { // Children cascade from users; the LIKE keeps this scoped to authtest rows. await db.delete(users).where(like(users.emailKey, `authtest-%`)); } /** A NextRequest — the same object shape Next.js passes to route handlers. */ function logoutRequest(headers?: HeadersInit): NextRequest { return new NextRequest("http://localhost:3000/api/auth/logout", { method: "POST", ...(headers ? { headers } : {}), }); } function registerSuites() { describe("CSRF flow — double-submit guard on POST /api/auth/logout", () => { beforeAll(async () => { const user = await createUser({ email: BASE_EMAIL, name: "CSRF Flow Test", passwordHash: await hashPassword("Sicher1234!"), }); await db.insert(sessions).values({ id: issueToken().hash, userId: user.id, expiresAt: new Date(Date.now() + 60_000), }); }); test("the fixture user and session exist", async () => { const userRows = await db.select().from(users).where(eq(users.emailKey, BASE_EMAIL)); expect(userRows.length).toBe(1); const sessionRows = await db .select() .from(sessions) .where(eq(sessions.userId, userRows[0].id)); expect(sessionRows.length).toBe(1); }); test("a logout without a CSRF token is blocked with 403 csrf_failed", async () => { const response = await logoutPOST(logoutRequest()); expect(response.status).toBe(403); expect(await response.json()).toEqual({ error: "csrf_failed" }); }); test("a logout with a matching cookie + header succeeds", async () => { const token = issueCsrfToken(); // This is the one call in the suite that reaches the real route handler // past the CSRF guard, so it hits `destroySession()` -> `cookies()`. // Outside actual Next.js request handling there is no request-scoped // AsyncLocalStorage for `cookies()` to read, so it throws; the route // already treats that as non-fatal (logout must not "stick" a user // signed in) and logs it via console.error. That's expected only in // this direct-handler test harness, so it's muted here rather than in // the route, which must keep logging real failures in production. const originalConsoleError = console.error; console.error = () => {}; let response: Response; try { response = await logoutPOST( logoutRequest({ cookie: `${CSRF_COOKIE}=${token}`, [CSRF_HEADER]: token, }) ); } finally { console.error = originalConsoleError; } expect(response.status).toBe(200); const body = (await response.json()) as { status?: string }; expect(body.status).toBe("signed_out"); }); test("a logout with a mismatched header is blocked with 403", async () => { const response = await logoutPOST( logoutRequest({ cookie: `${CSRF_COOKIE}=${issueCsrfToken()}`, [CSRF_HEADER]: issueCsrfToken(), }) ); expect(response.status).toBe(403); expect(await response.json()).toEqual({ error: "csrf_failed" }); }); test("a logout with a cookie but no header is blocked with 403", async () => { const response = await logoutPOST( logoutRequest({ cookie: `${CSRF_COOKIE}=${issueCsrfToken()}`, }) ); expect(response.status).toBe(403); expect(await response.json()).toEqual({ error: "csrf_failed" }); }); }); } async function main() { if (!(await isDatabaseAvailable())) { console.log( [ "", " SKIPPED — no database reachable at DATABASE_URL.", "", " Start one and apply the schema, then re-run:", " docker compose up -d postgres", " npm run db:push", " npx tsx tests/integration/csrf_flow.test.ts", "", ].join("\n") ); await pool.end(); return; } // Leftovers from an interrupted earlier run would break the fixture. await cleanup(); registerSuites(); let passed = false; try { passed = await runAllTests(); } finally { await cleanup(); await pool.end(); } if (!passed) process.exit(1); } main().catch(async (error) => { console.error("CSRF flow suite crashed:", error); await pool.end().catch(() => undefined); process.exit(1); });