vps-health-api/__tests__/auth.test.js
le king fu 64e4669619 feat(defenseurs): serve the Sergent's findings state at GET /defenseurs/state
The Sergent now persists per-finding state (firstSeen, unverified, 30-day
history) in findings-state.json next to status.json. The admin dashboard
needs it for the drill-down, since it is the only source carrying a
finding's age.

- New read route GET /defenseurs/state, path from
  DEFENSEURS_FINDINGS_STATE_PATH (default /data/defenseurs/findings-state.json),
  already covered by the /data/defenseurs mount.
- Extract serveJsonFile() so /defenseurs and /defenseurs/state share one
  contract: raw content, or 200 {status:"no_data"} on any read error.
- Tests: new state.test.js (byte-identical body, no_data, path isolation,
  401 x3, 404 on POST) and the route added to the auth.test.js matrix.
- Docs: README and CLAUDE.md endpoint, config and mount sections; the README
  mount list now matches the real /data/defenseurs directory mount.

Fixes #27

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:31:26 -04:00

209 lines
8 KiB
JavaScript

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 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 GET route reachable by the handler. Any new route must be added here.
// POST /hosts/<id> is not a GET, so it gets its own describe block below —
// but it is covered, and any future route must be too.
const ROUTES = [
"/health",
"/defenseurs",
"/defenseurs/findings",
"/defenseurs/state",
"/reports/scans",
"/hosts",
];
// Ingest token, distinct from the read token on purpose: a read token must not
// open the write path, and a write token must not open the read routes.
const INGEST_TOKEN = "test-ingest-token";
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");
process.env.DEFENSEURS_FINDINGS_STATE_PATH = path.join(tmpDir, "findings-state.json");
process.env.HOSTS_DIR = path.join(tmpDir, "hosts");
process.env.HOSTS_ALLOWED_IDS = "thinkpad";
// Configured on purpose: an unset ingest token answers 503 before the header
// is ever read, which would hide the 401 these tests are here to pin.
process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN;
// 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 read 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");
});
});
// The ingest path is the only write surface on this service, and the only one
// reachable with a token that is NOT the read token. Both directions of that
// separation are pinned here: a read token must not write, a write token must
// not read. The gate answers before the request body is ever buffered, so an
// unauthenticated caller cannot make the server hold 4 KiB on its behalf.
describe("auth gate — ingest route", () => {
test("401 on POST /hosts/<id> with no Authorization header", async () => {
const { status, body } = await request("/hosts/thinkpad", { method: "POST", auth: null });
expect(status).toBe(401);
expect(body.error).toBe("Unauthorized");
});
test("401 on POST /hosts/<id> with the read token", async () => {
const { status, body } = await request("/hosts/thinkpad", {
method: "POST",
auth: `Bearer ${TOKEN}`,
});
expect(status).toBe(401);
expect(body.error).toBe("Unauthorized");
});
test("401 on GET /hosts with the ingest token", async () => {
const { status, body } = await request("/hosts", { auth: `Bearer ${INGEST_TOKEN}` });
expect(status).toBe(401);
expect(body.error).toBe("Unauthorized");
});
// An unknown host id never reaches the handler: the route regex is the
// path-traversal control, so a malformed id is a 404, not a 403 — and that
// 404 arrives before authentication, like every other routing decision.
test("404 (not 401, not 403) on POST /hosts/../../etc/passwd", async () => {
const { status } = await request("/hosts/../../etc/passwd", { method: "POST", auth: null });
expect(status).toBe(404);
});
});