const crypto = require("node:crypto"); const fp = require("fastify-plugin"); const { buildApp } = require("../../src/app"); const { getConfig } = require("../../src/config"); const { generateSixDigitCode, hashAuthCode, timingSafeEqualHex, } = require("../../src/utils/crypto"); const STRONG_PEPPER = "test-auth-code-pepper-with-at-least-32-bytes"; function stubBaseEnv(nodeEnv = "test") { vi.stubEnv("NODE_ENV", nodeEnv); vi.stubEnv("JWT_SECRET", "test-jwt-secret"); vi.stubEnv("DATABASE_URL", ""); vi.stubEnv("AUTH_CODE_PEPPER", nodeEnv === "production" ? STRONG_PEPPER : ""); vi.stubEnv("TRUST_PROXY_HOPS", nodeEnv === "production" ? "1" : undefined); vi.stubEnv("JOBS_ENABLED", "false"); vi.stubEnv("METRICS_ENABLED", "false"); vi.stubEnv("SERVE_CLIENT_DIST", "false"); vi.stubEnv("OPENAPI_DOCS_ENABLED", "false"); } function createPrismaPlugin() { return fp(async (app) => { app.decorate("prisma", { $queryRaw: vi.fn().mockResolvedValue([{ ok: 1 }]), employee: { findUnique: vi.fn(), }, }); }, { name: "test-prisma" }); } async function buildIpProbeApp() { const app = await buildApp({ prismaPlugin: createPrismaPlugin(), startJobs: vi.fn(async () => ({ driver: "disabled" })), }); app.get("/__test/ip", async (request) => ({ ip: request.ip, ips: request.ips, })); return app; } describe("auth security configuration", () => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); }); it("uses crypto.randomInt for padded six-digit codes", () => { const randomSpy = vi.spyOn(crypto, "randomInt").mockReturnValue(42); expect(generateSixDigitCode()).toBe("000042"); expect(randomSpy).toHaveBeenCalledWith(0, 1_000_000); }); it("separates auth code hashes by purpose and compares them safely", () => { const common = { employeeId: "11111111-1111-4111-8111-111111111111", code: "123456", pepper: STRONG_PEPPER, }; const emailHash = hashAuthCode({ ...common, purpose: "email_login" }); const resetHash = hashAuthCode({ ...common, purpose: "password_reset" }); expect(emailHash).not.toBe(resetHash); expect(timingSafeEqualHex(emailHash, emailHash)).toBe(true); expect(timingSafeEqualHex(emailHash, resetHash)).toBe(false); expect(timingSafeEqualHex(emailHash, "not-hex")).toBe(false); }); it("fails closed when production has no auth code pepper", () => { stubBaseEnv("production"); vi.stubEnv("AUTH_CODE_PEPPER", ""); expect(() => getConfig()).toThrow("Missing required env: AUTH_CODE_PEPPER"); }); it.each([ "", "short-secret", ])("rejects a weak production auth code pepper", (pepper) => { stubBaseEnv("production"); vi.stubEnv("AUTH_CODE_PEPPER", pepper); expect(() => getConfig()).toThrow("AUTH_CODE_PEPPER must be a non-placeholder secret"); }); it("rejects a production auth code pepper reused as the JWT secret", () => { stubBaseEnv("production"); vi.stubEnv("JWT_SECRET", STRONG_PEPPER); vi.stubEnv("AUTH_CODE_PEPPER", STRONG_PEPPER); expect(() => getConfig()).toThrow("AUTH_CODE_PEPPER must be independent from JWT_SECRET"); }); it("forbids development password reset code logging in production", () => { stubBaseEnv("production"); vi.stubEnv("DEV_SHOW_PASSWORD_RESET_CODE", "true"); expect(() => getConfig()).toThrow("DEV_SHOW_PASSWORD_RESET_CODE is forbidden in production"); }); it.each([ [undefined, "Missing required env: TRUST_PROXY_HOPS"], ["", "Invalid TRUST_PROXY_HOPS"], ["0", "Invalid TRUST_PROXY_HOPS"], ["2", "Invalid TRUST_PROXY_HOPS"], ["true", "Invalid TRUST_PROXY_HOPS"], ])("requires exactly one trusted proxy hop in production", (value, error) => { stubBaseEnv("production"); vi.stubEnv("TRUST_PROXY_HOPS", value); expect(() => getConfig()).toThrow(error); }); it.each([ ["PASSWORD_RESET_MAX_ATTEMPTS", "0"], ["PASSWORD_RESET_LOCK_SECONDS", "-1"], ["EMAIL_LOGIN_CODE_TTL_SECONDS", "NaN"], ["EMAIL_LOGIN_CODE_MAX_ATTEMPTS", "21"], ])("rejects unsafe numeric auth configuration", (name, value) => { stubBaseEnv(); vi.stubEnv(name, value); expect(() => getConfig()).toThrow(`Invalid ${name}`); }); it.each([ [undefined, 365], ["180", 180], ["365", 365], ])("accepts the configured reimbursement age policy", (value, expected) => { stubBaseEnv(); vi.stubEnv("REIMBURSEMENT_MAX_AGE_DAYS", value); expect(getConfig().reimbursementMaxAgeDays).toBe(expected); }); it.each(["0", "181", "366", "invalid"])( "rejects unsupported reimbursement age policy %s", (value) => { stubBaseEnv(); vi.stubEnv("REIMBURSEMENT_MAX_AGE_DAYS", value); expect(() => getConfig()).toThrow("Invalid REIMBURSEMENT_MAX_AGE_DAYS"); }, ); it("ignores a forged X-Forwarded-For header by default", async () => { stubBaseEnv(); const app = await buildIpProbeApp(); try { const response = await app.inject({ method: "GET", url: "/__test/ip", headers: { "x-forwarded-for": "10.10.10.10" }, }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ ip: "127.0.0.1" }); } finally { await app.close(); } }, 20_000); it("trusts only the nearest address supplied by one controlled proxy", async () => { stubBaseEnv(); vi.stubEnv("TRUST_PROXY_HOPS", "1"); const app = await buildIpProbeApp(); try { const response = await app.inject({ method: "GET", url: "/__test/ip", headers: { "x-forwarded-for": "10.10.10.10, 192.168.1.20" }, }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ ip: "192.168.1.20", ips: ["127.0.0.1", "192.168.1.20"], }); } finally { await app.close(); } }, 20_000); });