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>
133 lines
4.5 KiB
JavaScript
133 lines
4.5 KiB
JavaScript
const http = require("node:http");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
|
|
// GET /defenseurs/state serves the Sergent's findings-state.json, and shares
|
|
// serveJsonFile() with GET /defenseurs. Both routes are covered here: the
|
|
// helper extraction must leave /defenseurs byte-identical to what it served.
|
|
|
|
const TOKEN = "test-token";
|
|
const INGEST_TOKEN = "test-ingest-token";
|
|
|
|
// Deliberately non-canonical JSON (odd spacing, key order, trailing newline):
|
|
// a handler that parsed and re-serialized the file would not reproduce it.
|
|
const STATE_RAW =
|
|
'{ "schemaVersion":1, "findings": {"a-deps-1": {"firstSeen":"2026-09-01T00:00:00.000Z"}},"history":[] }\n';
|
|
const STATUS_RAW = '{"timestamp":"2026-09-24T00:00:00.000Z", "agents": [] }\n';
|
|
|
|
let tmpDir;
|
|
let statePath;
|
|
let statusPath;
|
|
let server;
|
|
let baseUrl;
|
|
|
|
function startServer() {
|
|
// Fresh import so module-level constants pick up the stubbed env.
|
|
delete require.cache[require.resolve("../index.js")];
|
|
const { handler } = require("../index.js");
|
|
server = http.createServer(handler);
|
|
return new Promise((resolve) => {
|
|
server.listen(0, () => {
|
|
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
function stopServer() {
|
|
return new Promise((resolve) => {
|
|
if (!server) return resolve();
|
|
server.close(() => resolve());
|
|
});
|
|
}
|
|
|
|
async function request(route, { method = "GET", auth = `Bearer ${TOKEN}` } = {}) {
|
|
const headers = {};
|
|
if (auth) headers.Authorization = auth;
|
|
const res = await fetch(`${baseUrl}${route}`, { method, headers });
|
|
return { status: res.status, text: await res.text() };
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-state-test-"));
|
|
statePath = path.join(tmpDir, "findings-state.json");
|
|
statusPath = path.join(tmpDir, "status.json");
|
|
|
|
process.env.HEALTH_TOKEN = TOKEN;
|
|
process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN;
|
|
process.env.DEFENSEURS_FINDINGS_STATE_PATH = statePath;
|
|
process.env.DEFENSEURS_STATUS_PATH = statusPath;
|
|
|
|
await startServer();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await stopServer();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("GET /defenseurs/state", () => {
|
|
test("200 with the file content byte for byte when present", async () => {
|
|
fs.writeFileSync(statePath, STATE_RAW);
|
|
const { status, text } = await request("/defenseurs/state");
|
|
expect(status).toBe(200);
|
|
expect(text).toBe(STATE_RAW);
|
|
});
|
|
|
|
test("200 no_data when the file is absent", async () => {
|
|
const { status, text } = await request("/defenseurs/state");
|
|
expect(status).toBe(200);
|
|
expect(JSON.parse(text)).toEqual({ status: "no_data" });
|
|
});
|
|
|
|
test("serves findings-state.json, never status.json", async () => {
|
|
fs.writeFileSync(statusPath, STATUS_RAW);
|
|
const { status, text } = await request("/defenseurs/state");
|
|
expect(status).toBe(200);
|
|
expect(JSON.parse(text)).toEqual({ status: "no_data" });
|
|
});
|
|
|
|
test("401 with no Authorization header", async () => {
|
|
fs.writeFileSync(statePath, STATE_RAW);
|
|
const { status, text } = await request("/defenseurs/state", { auth: null });
|
|
expect(status).toBe(401);
|
|
expect(text).not.toContain("schemaVersion");
|
|
});
|
|
|
|
test("401 with a wrong bearer token", async () => {
|
|
fs.writeFileSync(statePath, STATE_RAW);
|
|
const { status, text } = await request("/defenseurs/state", { auth: "Bearer wrong" });
|
|
expect(status).toBe(401);
|
|
expect(text).not.toContain("schemaVersion");
|
|
});
|
|
|
|
test("401 with the ingest token", async () => {
|
|
fs.writeFileSync(statePath, STATE_RAW);
|
|
const { status, text } = await request("/defenseurs/state", {
|
|
auth: `Bearer ${INGEST_TOKEN}`,
|
|
});
|
|
expect(status).toBe(401);
|
|
expect(text).not.toContain("schemaVersion");
|
|
});
|
|
|
|
test("404 (not 401) on POST without Authorization", async () => {
|
|
const { status } = await request("/defenseurs/state", { method: "POST", auth: null });
|
|
expect(status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe("GET /defenseurs — unchanged by the serveJsonFile extraction", () => {
|
|
test("200 with status.json byte for byte when present", async () => {
|
|
fs.writeFileSync(statusPath, STATUS_RAW);
|
|
const { status, text } = await request("/defenseurs");
|
|
expect(status).toBe(200);
|
|
expect(text).toBe(STATUS_RAW);
|
|
});
|
|
|
|
test("200 no_data when status.json is absent", async () => {
|
|
const { status, text } = await request("/defenseurs");
|
|
expect(status).toBe(200);
|
|
expect(JSON.parse(text)).toEqual({ status: "no_data" });
|
|
});
|
|
});
|