test(auth): cover the 401 gate on all four read routes
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
This commit is contained in:
parent
79cb813767
commit
babfd1f4b9
1 changed files with 154 additions and 0 deletions
154
__tests__/auth.test.js
Normal file
154
__tests__/auth.test.js
Normal file
|
|
@ -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 <token>`, 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue