From babfd1f4b91df1990f3e330123257bbe48b55b18 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sun, 16 Aug 2026 11:36:05 -0400 Subject: [PATCH] test(auth): cover the 401 gate on all four read routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 14 existing tests all hit /defenseurs/findings — /health, /defenseurs and /reports/scans had no authentication coverage at all. This is the safety net for the routing/auth refactor that comes next: a miswiring could expose the parc-wide Defenseurs reports publicly without turning a single test red. Adds __tests__/auth.test.js (19 tests), following the findings.test.js pattern (real http server + temp dir): - 401 on all four routes with no Authorization header - 401 on all four routes with a wrong bearer token - 401 on malformed headers (no scheme, lowercase scheme, scheme only) - 401 fail-closed when HEALTH_TOKEN is unset - 404 on unknown routes and on POST against existing routes These describe current behaviour: index.js is untouched. Two tests document that route/method validation runs before authentication, so an unauthenticated caller gets 404 rather than 401 on those paths. Resolves #12 --- __tests__/auth.test.js | 154 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 __tests__/auth.test.js diff --git a/__tests__/auth.test.js b/__tests__/auth.test.js new file mode 100644 index 0000000..ec9cc46 --- /dev/null +++ b/__tests__/auth.test.js @@ -0,0 +1,154 @@ +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"); + }); +});