vps-health-api/__tests__/findings.test.js
le king fu e88a044711 feat(defenseurs): add GET /defenseurs/findings?project=X route
Drill-down endpoint exposing detailed findings per project. Resolves the
HTTP gap for the Vercel admin dashboard, which cannot SSH/Tailscale to
the VPS, plus a future portable /analyse-vulnerabilite skill.

- Project -> agent lookup via /data/defenseurs/agents-map.json (Sergent snapshot)
- findLatestReportForAgent scans REPORTS_DIR + REPORTS_DIR/archive (post-07:30 UTC rotation)
- Filters: category exact match, severity threshold inclusive upward
- Asymmetric severity rule: default hides LOW+INFO; ?severity=LOW returns
  LOW+MEDIUM+HIGH+CRITICAL but still hides INFO; INFO opt-in via explicit param
- Distinguishes "report present + scan clean" (no status field) from
  "no report at all" ({findings:[], status:"no_data"})
- Bootstraps vitest (devDep; runtime stays 0-dep), 14 tests covering auth,
  validation, filters, asymmetry, mtime selection, error paths
- Refactor: export handler so tests can spin up ephemeral servers; server.listen
  guarded by require.main === module

Closes #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:56:56 -04:00

202 lines
7.3 KiB
JavaScript

const http = require("node:http");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const TOKEN = "test-token";
function makeFinding(id, severity, category) {
return {
id,
severity,
category,
title: `${id} title`,
description: `${id} desc`,
location: `${id}/loc`,
recommendation: `${id} reco`,
firstSeen: "2026-05-01T00:00:00.000Z",
status: "open",
};
}
const ALL_FINDINGS = [
makeFinding("c1", "CRITICAL", "deps"),
makeFinding("h1", "HIGH", "secrets"),
makeFinding("m1", "MEDIUM", "deps"),
makeFinding("l1", "LOW", "code"),
makeFinding("i1", "INFO", "infra"),
];
let tmpDir;
let reportsDir;
let agentsMapPath;
let server;
let baseUrl;
let handler;
function writeReport(agent, timestamp, findings, subdir = "") {
const dir = subdir ? path.join(reportsDir, subdir) : reportsDir;
fs.mkdirSync(dir, { recursive: true });
const safeTs = timestamp.replace(/[:.]/g, "-");
const file = path.join(dir, `defenseur-${agent}_${safeTs}.json`);
fs.writeFileSync(
file,
JSON.stringify({ agent: `defenseur-${agent}`, timestamp, findings }),
);
return file;
}
function writeAgentsMap(map) {
fs.writeFileSync(agentsMapPath, JSON.stringify(map));
}
function writeRawAgentsMap(raw) {
fs.writeFileSync(agentsMapPath, raw);
}
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 get(path, { auth = `Bearer ${TOKEN}` } = {}) {
const headers = {};
if (auth) headers.Authorization = auth;
const res = await fetch(`${baseUrl}${path}`, { headers });
const body = await res.json().catch(() => ({}));
return { status: res.status, body };
}
beforeEach(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-test-"));
reportsDir = path.join(tmpDir, "reports");
fs.mkdirSync(reportsDir, { recursive: true });
agentsMapPath = path.join(tmpDir, "agents-map.json");
process.env.HEALTH_TOKEN = TOKEN;
process.env.REPORTS_DIR = reportsDir;
process.env.DEFENSEURS_AGENTS_MAP_PATH = agentsMapPath;
writeAgentsMap({ "la-suite-booking": "defenseur-booking" });
await startServer();
});
afterEach(async () => {
await stopServer();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe("GET /defenseurs/findings", () => {
test("401 when no Authorization header", async () => {
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
const { status } = await get("/defenseurs/findings?project=la-suite-booking", { auth: null });
expect(status).toBe(401);
});
test("401 on invalid token", async () => {
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
const { status } = await get("/defenseurs/findings?project=la-suite-booking", { auth: "Bearer wrong" });
expect(status).toBe(401);
});
test("400 when project param is missing", async () => {
const { status, body } = await get("/defenseurs/findings");
expect(status).toBe(400);
expect(body.error).toMatch(/project/);
});
test("404 on unknown project", async () => {
const { status } = await get("/defenseurs/findings?project=unknown-x");
expect(status).toBe(404);
});
test("200 default (no severity) returns MEDIUM+HIGH+CRITICAL, hides LOW+INFO", async () => {
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
const { status, body } = await get("/defenseurs/findings?project=la-suite-booking");
expect(status).toBe(200);
expect(body.agent).toBe("defenseur-booking");
expect(body.project).toBe("la-suite-booking");
expect(body.timestamp).toBe("2026-05-12T00:00:00.000Z");
expect(body.findings).toHaveLength(3);
expect(body.findings.map((f) => f.severity).sort()).toEqual(["CRITICAL", "HIGH", "MEDIUM"]);
expect(body.status).toBeUndefined();
});
test("200 ?category=deps filters by category exact match", async () => {
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
const { status, body } = await get("/defenseurs/findings?project=la-suite-booking&category=deps");
expect(status).toBe(200);
expect(body.findings.map((f) => f.id).sort()).toEqual(["c1", "m1"]);
});
test("200 ?severity=HIGH returns HIGH+CRITICAL only", async () => {
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
const { body } = await get("/defenseurs/findings?project=la-suite-booking&severity=HIGH");
expect(body.findings.map((f) => f.severity).sort()).toEqual(["CRITICAL", "HIGH"]);
});
test("200 ?severity=LOW returns LOW+MEDIUM+HIGH+CRITICAL but hides INFO", async () => {
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
const { body } = await get("/defenseurs/findings?project=la-suite-booking&severity=LOW");
expect(body.findings.map((f) => f.severity).sort()).toEqual(["CRITICAL", "HIGH", "LOW", "MEDIUM"]);
expect(body.findings.some((f) => f.severity === "INFO")).toBe(false);
});
test("200 ?severity=INFO returns INFO only", async () => {
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
const { body } = await get("/defenseurs/findings?project=la-suite-booking&severity=INFO");
expect(body.findings.map((f) => f.severity)).toEqual(["INFO"]);
});
test("200 ?category=deps&severity=CRITICAL returns just c1", async () => {
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
const { body } = await get("/defenseurs/findings?project=la-suite-booking&category=deps&severity=CRITICAL");
expect(body.findings.map((f) => f.id)).toEqual(["c1"]);
});
test("200 picks latest report (mixed top-level + archive)", async () => {
writeReport("booking", "2026-05-10T00:00:00.000Z", [makeFinding("old", "HIGH", "deps")], "archive");
writeReport("booking", "2026-05-12T00:00:00.000Z", [makeFinding("new", "HIGH", "deps")]);
const { body } = await get("/defenseurs/findings?project=la-suite-booking&severity=HIGH");
expect(body.timestamp).toBe("2026-05-12T00:00:00.000Z");
expect(body.findings.map((f) => f.id)).toEqual(["new"]);
});
test("200 report present + findings:[] (scan clean) returns no status field", async () => {
writeReport("booking", "2026-05-12T00:00:00.000Z", []);
const { status, body } = await get("/defenseurs/findings?project=la-suite-booking");
expect(status).toBe(200);
expect(body.findings).toEqual([]);
expect(body.status).toBeUndefined();
expect(body.agent).toBe("defenseur-booking");
});
test("200 report absent returns {findings:[], status:'no_data'}", async () => {
const { status, body } = await get("/defenseurs/findings?project=la-suite-booking");
expect(status).toBe(200);
expect(body).toEqual({ findings: [], status: "no_data" });
});
test("500 on corrupt agents-map.json", async () => {
writeRawAgentsMap("{not valid json");
const { status, body } = await get("/defenseurs/findings?project=la-suite-booking");
expect(status).toBe(500);
expect(body.error).toBe("Internal error");
});
});