Files
scan-receipts/tests/e2e/security_headers.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

123 lines
4.1 KiB
TypeScript

/**
* Security Headers Suite — Task A (HSTS / HTTPS-only)
*
* Verifies next.config.ts: the Strict-Transport-Security header is configured
* with the full "max-age=63072000; includeSubDomains; preload" directive, is
* emitted only when the request actually arrived over HTTPS
* (x-forwarded-proto: https), and the remaining security headers stay
* unconditional on the catch-all rule. Pure config logic — no database or
* running server required.
*/
import { describe, test, expect } from "./runner";
import configModule from "../../next.config";
// Under tsx (ESM) the CJS-style default export can arrive wrapped as
// `{ default: nextConfig }`; unwrap it so `config.headers()` is callable in
// either interop mode.
const config = ((configModule as { default?: typeof configModule }).default ??
configModule) as typeof configModule;
interface HeaderItem {
key: string;
value: string;
}
interface HeaderCondition {
type: string;
key: string;
value?: string;
}
interface HeaderRule {
source: string;
has?: HeaderCondition[];
headers: HeaderItem[];
}
async function catchAllRules(): Promise<HeaderRule[]> {
// `headers` is optional on the NextConfig type; next.config.ts always defines it.
const rules = (await config.headers!()) as HeaderRule[];
return rules.filter((rule) => rule.source === "/(.*)");
}
function findHstsRule(rules: HeaderRule[]): HeaderRule | undefined {
return rules.find((rule) =>
rule.headers.some(
(header) => header.key.toLowerCase() === "strict-transport-security"
)
);
}
describe("Security headers — HSTS (HTTPS-only)", () => {
test("the Strict-Transport-Security header is configured on the catch-all rule", async () => {
const rules = await catchAllRules();
expect(findHstsRule(rules)).toBeDefined();
});
test("HSTS carries max-age=63072000, includeSubDomains and preload", async () => {
const rule = findHstsRule(await catchAllRules());
expect(rule).toBeDefined();
const hsts = rule!.headers.find(
(header) => header.key.toLowerCase() === "strict-transport-security"
);
expect(hsts).toBeDefined();
expect(hsts!.value).toContain("max-age=63072000");
expect(hsts!.value).toContain("includeSubDomains");
expect(hsts!.value).toContain("preload");
});
test("HSTS is emitted only when the request arrived over HTTPS (x-forwarded-proto)", async () => {
const rule = findHstsRule(await catchAllRules());
expect(rule).toBeDefined();
expect(rule!.has).toBeDefined();
const conditioned = rule!.has!.some(
(condition) =>
condition.type === "header" &&
condition.key.toLowerCase() === "x-forwarded-proto" &&
condition.value === "https"
);
expect(conditioned).toBe(true);
});
test("the remaining security headers stay unconditional on the catch-all rule", async () => {
const rules = await catchAllRules();
const unconditional = rules.filter((rule) => !rule.has);
expect(unconditional.length).toBeGreaterThan(0);
const allHeaders = unconditional.flatMap((rule) => rule.headers);
const keys = new Set(allHeaders.map((header) => header.key.toLowerCase()));
expect(keys.has("x-frame-options")).toBe(true);
expect(keys.has("x-content-type-options")).toBe(true);
expect(keys.has("referrer-policy")).toBe(true);
expect(keys.has("permissions-policy")).toBe(true);
expect(keys.has("content-security-policy")).toBe(true);
const xfo = allHeaders.find(
(header) => header.key.toLowerCase() === "x-frame-options"
);
const xcto = allHeaders.find(
(header) => header.key.toLowerCase() === "x-content-type-options"
);
expect(xfo).toBeDefined();
expect(xcto).toBeDefined();
expect(xfo!.value).toBe("DENY");
expect(xcto!.value).toBe("nosniff");
expect(
allHeaders.some(
(header) =>
header.key.toLowerCase() === "referrer-policy" &&
header.value.includes("strict-origin-when-cross-origin")
)
).toBe(true);
expect(
allHeaders.some(
(header) =>
header.key.toLowerCase() === "content-security-policy" &&
header.value.length > 0
)
).toBe(true);
});
});