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>
202 lines
7.3 KiB
JavaScript
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");
|
|
});
|
|
});
|