const http = require("node:http"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); // Authentication safety net for the four read routes. // // findings.test.js and health.test.js cover what each route *returns*; this // file covers the gate in front of all of them. The small overlap on // /defenseurs/findings and /health is deliberate: the value of this file is the // complete route x failure-mode matrix in one place, so a routing/auth refactor // that only rewires part of the table still fails loudly here. // // These tests describe the CURRENT behaviour of index.js. If one of them turns // red after a refactor, the refactor changed the security contract. const TOKEN = "test-token"; // Every route reachable by the handler. Any new route must be added here. const ROUTES = ["/health", "/defenseurs", "/defenseurs/findings", "/reports/scans"]; let tmpDir; let server; let baseUrl; let handler; function startServer() { // Fresh import so module-level constants pick up the stubbed env. delete require.cache[require.resolve("../index.js")]; ({ handler } = require("../index.js")); server = http.createServer(handler); return new Promise((resolve) => { server.listen(0, () => { const { port } = server.address(); baseUrl = `http://127.0.0.1:${port}`; resolve(); }); }); } function stopServer() { return new Promise((resolve) => { if (!server) return resolve(); server.close(() => resolve()); }); } async function restartServer() { await stopServer(); await startServer(); } async function request(path, { method = "GET", auth = `Bearer ${TOKEN}` } = {}) { const headers = {}; if (auth) headers.Authorization = auth; const res = await fetch(`${baseUrl}${path}`, { method, headers }); const body = await res.json().catch(() => ({})); return { status: res.status, body }; } beforeEach(async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-auth-test-")); process.env.HEALTH_TOKEN = TOKEN; // Point every filesystem read at the temp dir so a rejected request can never // fall back to the host's real /data/defenseurs. process.env.REPORTS_DIR = path.join(tmpDir, "reports"); process.env.DEFENSEURS_AGENTS_MAP_PATH = path.join(tmpDir, "agents-map.json"); process.env.DEFENSEURS_STATUS_PATH = path.join(tmpDir, "status.json"); // Closed port: /health must never reach the real IdP. If the auth gate ever // fails open, the request errors out fast instead of hitting production. process.env.LOGTO_HEALTH_URL = "http://127.0.0.1:1/oidc/.well-known/openid-configuration"; await startServer(); }); afterEach(async () => { await stopServer(); fs.rmSync(tmpDir, { recursive: true, force: true }); }); describe("auth gate — missing Authorization header", () => { test.each(ROUTES)("401 on GET %s with no Authorization header", async (route) => { const { status, body } = await request(route, { auth: null }); expect(status).toBe(401); // Pin the gate itself: auth runs before any query-param validation, so // /defenseurs/findings and /reports/scans must answer 401, never 400. expect(body.error).toBe("Unauthorized"); }); }); describe("auth gate — invalid token", () => { test.each(ROUTES)("401 on GET %s with a wrong bearer token", async (route) => { const { status, body } = await request(route, { auth: "Bearer wrong-token" }); expect(status).toBe(401); expect(body.error).toBe("Unauthorized"); }); }); describe("auth gate — malformed Authorization header", () => { // The header is compared byte for byte against `Bearer `, so these // near-misses are all rejected. /defenseurs stands in for the four routes: // it serves the parc-wide Defenseurs report and is the costliest to leak. const MALFORMED = [ ["raw token without the Bearer scheme", TOKEN], ["lowercase scheme", `bearer ${TOKEN}`], ["scheme with no token", "Bearer"], ]; test.each(MALFORMED)("401 on GET /defenseurs — %s", async (_label, header) => { const { status, body } = await request("/defenseurs", { auth: header }); expect(status).toBe(401); expect(body.error).toBe("Unauthorized"); }); }); describe("auth gate — fail-closed when HEALTH_TOKEN is unset", () => { test("401 on GET /defenseurs even with a well-formed bearer token", async () => { delete process.env.HEALTH_TOKEN; await restartServer(); const { status, body } = await request("/defenseurs", { auth: `Bearer ${TOKEN}` }); expect(status).toBe(401); expect(body.error).toBe("HEALTH_TOKEN not configured"); }); }); describe("routing", () => { test("404 on an unknown route", async () => { const { status, body } = await request("/nope"); expect(status).toBe(404); expect(body.error).toBe("Not found"); }); test.each(ROUTES)("404 on POST %s (method not allowed)", async (route) => { const { status, body } = await request(route, { method: "POST" }); expect(status).toBe(404); expect(body.error).toBe("Not found"); }); // Current ordering: the route/method check runs BEFORE authentication, so an // unauthenticated caller gets 404 rather than 401 on these. Documented as-is; // any refactor that flips the order will turn these red on purpose. test("404 (not 401) on an unknown route without Authorization", async () => { const { status, body } = await request("/nope", { auth: null }); expect(status).toBe(404); expect(body.error).toBe("Not found"); }); test("404 (not 401) on POST /health without Authorization", async () => { const { status, body } = await request("/health", { method: "POST", auth: null }); expect(status).toBe(404); expect(body.error).toBe("Not found"); }); });