/** * Cookie Flags Suite * * Every auth cookie must be hardened the same way: httpOnly, a deliberate * SameSite policy, Secure whenever the app serves TLS, path "/", and a sane * lifetime. All writers (session, guest, OAuth handshake, CSRF) go through the * single `cookieSecurityOptions` builder, so one test of the builder plus the * public option shapes covers every cookie the auth system can write. The * CSRF cookie is the sole deliberate exception: it overrides httpOnly to * false so client JS can read it, everything else stays shared. * Pure logic only — no server, no database. * * Run standalone: npx tsx tests/e2e/cookie_flags.test.ts */ import { describe, test, expect, runAllTests } from "./runner"; import { cookieSecurityOptions } from "../../src/lib/auth/config"; import { sessionCookieOptions } from "../../src/lib/auth/session"; import { applyGuestCookie, GUEST_COOKIE } from "../../src/lib/auth/guest"; import { csrfCookieOptions } from "../../src/lib/auth/csrf"; import { NextResponse } from "next/server"; /** Must stay in lockstep with the writers: 10 min for the OAuth handshake, 1y for guests. */ const HANDSHAKE_MAX_AGE_SECONDS = 600; const GUEST_MAX_AGE_SECONDS = 60 * 60 * 24 * 365; const CSRF_MAX_AGE_SECONDS = 86400; describe("Cookie flags — central builder", () => { test("always pins httpOnly, path / and a deliberate SameSite", () => { const base = cookieSecurityOptions(); expect(base.httpOnly).toBe(true); expect(base.path).toBe("/"); expect(base.sameSite).toBe("lax"); }); test("the flags survive an override — extras cannot drop the defaults", () => { const withMaxAge = cookieSecurityOptions({ maxAge: HANDSHAKE_MAX_AGE_SECONDS }); expect(withMaxAge.httpOnly).toBe(true); expect(withMaxAge.path).toBe("/"); expect(withMaxAge.sameSite).toBe("lax"); expect(withMaxAge.maxAge).toBe(HANDSHAKE_MAX_AGE_SECONDS); }); test("secure follows the environment — production flips it on", () => { const options = cookieSecurityOptions(); // `isProduction` is read from NODE_ENV at module load; Secure must be on // exactly when the app runs in production (TLS) and off in local dev. expect(options.secure).toBe(process.env.NODE_ENV === "production"); }); test("a sameSite override is honoured — lax stays the default", () => { expect(cookieSecurityOptions().sameSite).toBe("lax"); expect(cookieSecurityOptions({ sameSite: "strict" }).sameSite).toBe("strict"); expect(cookieSecurityOptions({ sameSite: "none" }).sameSite).toBe("none"); }); }); describe("Cookie flags — session cookie", () => { test("sessionCookieOptions carries the hardening set plus the expiry", () => { const expiresAt = new Date(Date.now() + 60 * 60 * 1000); const options = sessionCookieOptions(expiresAt); expect(options.httpOnly).toBe(true); expect(options.sameSite).toBe("lax"); expect(options.path).toBe("/"); expect(options.secure).toBe(process.env.NODE_ENV === "production"); expect(options.expires).toBe(expiresAt); }); }); describe("Cookie flags — OAuth handshake", () => { test("the oauth options include the maxAge when one is passed", () => { const options = cookieSecurityOptions({ maxAge: HANDSHAKE_MAX_AGE_SECONDS }); expect(options.maxAge).toBe(HANDSHAKE_MAX_AGE_SECONDS); expect(options.httpOnly).toBe(true); expect(options.path).toBe("/"); expect(options.sameSite).toBe("lax"); }); }); describe("Cookie flags — guest cookie", () => { test("applyGuestCookie writes a fully hardened one-year cookie", () => { const response = NextResponse.json({}); applyGuestCookie(response, { bucket: "guest_test", cookieValue: "guest_test" }); const cookie = response.cookies.get(GUEST_COOKIE); expect(cookie?.httpOnly).toBe(true); expect(cookie?.sameSite).toBe("lax"); expect(cookie?.path).toBe("/"); expect(cookie?.maxAge).toBe(GUEST_MAX_AGE_SECONDS); expect(cookie?.secure).toBe(process.env.NODE_ENV === "production"); }); test("applyGuestCookie leaves the response untouched when there is nothing to persist", () => { const response = NextResponse.json({}); const out = applyGuestCookie(response, { bucket: "guest_test", cookieValue: null }); expect(out.cookies.get(GUEST_COOKIE)).toBeUndefined(); }); }); describe("Cookie flags — CSRF double-submit cookie", () => { test("csrfCookieOptions goes through the shared builder, with httpOnly deliberately off", () => { const options = csrfCookieOptions(); // httpOnly must be false here — client JS has to read this one to echo it // as a header — everything else still comes from cookieSecurityOptions. expect(options.httpOnly).toBe(false); expect(options.sameSite).toBe("lax"); expect(options.path).toBe("/"); expect(options.maxAge).toBe(CSRF_MAX_AGE_SECONDS); expect(options.secure).toBe(process.env.NODE_ENV === "production"); }); test("only httpOnly diverges from the shared builder's defaults", () => { const shared = cookieSecurityOptions({ maxAge: CSRF_MAX_AGE_SECONDS }); const csrf = csrfCookieOptions(); expect(csrf.sameSite).toBe(shared.sameSite); expect(csrf.path).toBe(shared.path); expect(csrf.secure).toBe(shared.secure); expect(csrf.maxAge).toBe(shared.maxAge); expect(csrf.httpOnly).not.toBe(shared.httpOnly); }); }); async function main() { const passed = await runAllTests(); if (!passed) process.exit(1); } main().catch((error) => { console.error("Cookie flags suite crashed:", error); process.exit(1); });