import { afterEach, describe, expect, it, vi } from "vitest"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; afterEach(async () => { vi.resetAllMocks(); vi.resetModules(); vi.doUnmock("nodemailer"); }); describe("A02 Security Misconfiguration", () => { it("enforces CORS allowlist in production", async () => { const { buildApp } = await import("../src/server"); const app = await buildApp({ NODE_ENV: "production", CORS_ORIGINS: "https://allowed.example.com", AUTH_DISABLED: false, SEED_DEFAULT_BUDGET: false, APP_ORIGIN: "https://allowed.example.com", }); await app.ready(); try { const allowedOrigin = "https://allowed.example.com"; const deniedOrigin = "https://denied.example.com"; const allowed = await app.inject({ method: "OPTIONS", url: "/health", headers: { origin: allowedOrigin, "access-control-request-method": "GET", }, }); expect(allowed.statusCode).toBe(204); expect(allowed.headers["access-control-allow-origin"]).toBe(allowedOrigin); expect(allowed.headers["access-control-allow-credentials"]).toBe("true"); const denied = await app.inject({ method: "OPTIONS", url: "/health", headers: { origin: deniedOrigin, "access-control-request-method": "GET", }, }); expect([204, 404]).toContain(denied.statusCode); expect(denied.headers["access-control-allow-origin"]).toBeUndefined(); expect(denied.headers["access-control-allow-credentials"]).toBeUndefined(); } finally { await app.close(); } }); it("applies expected CORS response headers at runtime for allowed origins", async () => { const { buildApp } = await import("../src/server"); const app = await buildApp({ NODE_ENV: "production", CORS_ORIGINS: "https://allowed.example.com", AUTH_DISABLED: false, SEED_DEFAULT_BUDGET: false, APP_ORIGIN: "https://allowed.example.com", }); await app.ready(); try { const response = await app.inject({ method: "OPTIONS", url: "/health", headers: { origin: "https://allowed.example.com", "access-control-request-method": "GET", }, }); expect(response.statusCode).toBe(204); expect(response.headers["access-control-allow-origin"]).toBe( "https://allowed.example.com" ); expect(response.headers["access-control-allow-credentials"]).toBe("true"); expect(response.headers.vary).toContain("Origin"); } finally { await app.close(); } }); it("disables SMTP debug logger in production mailer config", async () => { const verify = vi.fn(async () => undefined); const sendMail = vi.fn(async () => ({ messageId: "mock-message-id" })); const createTransport = vi.fn(() => ({ verify, sendMail })); vi.doMock("nodemailer", () => ({ default: { createTransport }, })); const { buildApp } = await import("../src/server"); const app = await buildApp({ NODE_ENV: "production", SMTP_HOST: "smtp.example.com", SMTP_PORT: 587, SMTP_REQUIRE_TLS: true, SMTP_TLS_REJECT_UNAUTHORIZED: true, SMTP_USER: "smtp-user", SMTP_PASS: "smtp-pass", CORS_ORIGINS: "https://allowed.example.com", APP_ORIGIN: "https://allowed.example.com", AUTH_DISABLED: false, SEED_DEFAULT_BUDGET: false, }); try { await app.ready(); expect(createTransport).toHaveBeenCalledTimes(1); const transportOptions = createTransport.mock.calls[0]?.[0] as Record; expect(transportOptions.requireTLS).toBe(true); expect(transportOptions.logger).toBe(false); expect(transportOptions.debug).toBe(false); } finally { await app.close(); } }); it("defines anti-framing headers in edge configs", () => { const root = resolve(__dirname, "..", ".."); const caddyProd = readFileSync(resolve(root, "Caddyfile.prod"), "utf8"); const caddyDev = readFileSync(resolve(root, "Caddyfile.dev"), "utf8"); const nginxProd = readFileSync( resolve(root, "deploy/nginx/skymoneybudget.com.conf"), "utf8" ); expect(caddyProd).toContain('X-Frame-Options "DENY"'); expect(caddyProd).toContain(`Content-Security-Policy "frame-ancestors 'none'"`); expect(caddyDev).toContain('X-Frame-Options "DENY"'); expect(caddyDev).toContain(`Content-Security-Policy "frame-ancestors 'none'"`); expect(nginxProd).toContain('add_header X-Frame-Options "DENY" always;'); expect(nginxProd).toContain( `add_header Content-Security-Policy "frame-ancestors 'none'" always;` ); }); it("keeps docker-compose service ports bound to localhost only", () => { const root = resolve(__dirname, "..", ".."); const compose = readFileSync(resolve(root, "docker-compose.yml"), "utf8"); expect(compose).toContain('- "127.0.0.1:5432:5432"'); expect(compose).toContain('- "127.0.0.1:8081:8080"'); }); });