Files
scan-receipts/tests/e2e/cookie_flags.test.ts
Timo 84b9987c49 Add full application: receipt scanning, auth, billing, and account deletion
Brings the working codebase (Next.js app, auth system, Stripe billing,
Docker/deploy config, tests, docs) into version control on top of the
placeholder initial commit, and adds account self-deletion (Danger Zone
in Settings, password + typed-email confirmation, cascading DB cleanup,
Stripe cancellation) per GDPR right-to-erasure.

Excludes local build caches, node_modules, and internal agent scratch
files; .gitignore hardened to keep those out going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 20:59:04 +02:00

132 lines
5.5 KiB
TypeScript

/**
* 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);
});