Follow-up to the /pr-review pass on PRs #18-#22. Three findings, none of which changed behaviour, all of which weakened a guarantee the stack was supposed to provide. 1. auth.test.js carried "any new route must be added here" but /hosts was never added when #13 introduced it, so no test asserted GET /hosts -> 401 on a missing header. The drift class the file exists to catch slipped on its first outing. ROUTES now covers /hosts, and a dedicated block pins both directions of the token separation: a read token cannot write, an ingest token cannot read. 2. ingestOversized() folded any transport error into 413, so the four oversized-body tests would have stayed green if the server had stopped writing the status and merely killed the socket - on the one path where "a status, not a dead socket" is the whole client contract. Transport errors are now surfaced instead of swallowed. 3. HOST_AGENT_TIMEOUT_MS was read by push-metrics.js but never exported by run-push.sh, so setting it in the documented env file did nothing. Now exported and documented. Also drops a claim from agent/README.md that the review proved false: the ingest/read token split buys no containment on the ThinkPad, which already stores HEALTH_TOKEN in cleartext for defenseur-auto. Losing that laptop compromises both, so they rotate together.
1078 lines
43 KiB
JavaScript
1078 lines
43 KiB
JavaScript
const http = require("node:http");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
|
|
// Coverage matrix for the workstation snapshot surface (issues #13 and #14).
|
|
//
|
|
// The first blocks landed with the implementation (#13) and pinned the handful
|
|
// of decisions that would be expensive to get wrong and silent to regress: the
|
|
// routing-before-auth ordering, the narrow host id regex as the traversal
|
|
// control, 401 winning over 403, the whitelist rebuild, and the server-stamped
|
|
// freshness. #14 grew that smoke pass into the exhaustive matrix that follows —
|
|
// authentication, allowlist, payload validation, sanitisation, freshness,
|
|
// listing degradation — because this is the first publicly writable surface on
|
|
// the API and the only one where a mistake is an intrusion rather than an
|
|
// outage.
|
|
//
|
|
// Two conventions are worth stating once, up front.
|
|
//
|
|
// * A malformed host id answers 404, never 403. new URL() normalises
|
|
// `/hosts/../../etc/passwd` down to `/etc/passwd`, while `..%2f..%2f`,
|
|
// `THINKPAD` and a 33-character id simply fail HOST_ROUTE_RE — none of them
|
|
// ever reaches a handler. 403 is reserved for ids that are well formed but
|
|
// absent from HOSTS_ALLOWED_IDS. If one of these turns red, the fix belongs
|
|
// in the test: widening the route regex would put raw request input back
|
|
// onto a file path, which is the very thing the narrow pattern prevents.
|
|
// * The freshness assertions freeze Date.now() instead of racing the wall
|
|
// clock, so the 899/900/901 boundary is exact rather than ±1s on a loaded
|
|
// machine.
|
|
|
|
const TOKEN = "test-token";
|
|
const INGEST_TOKEN = "test-ingest-token";
|
|
|
|
let tmpDir;
|
|
let hostsDir;
|
|
let server;
|
|
let baseUrl;
|
|
let handler;
|
|
|
|
function validSnapshot() {
|
|
return {
|
|
hostname: "thinkpad-x1",
|
|
uptime: 4242,
|
|
cpu: { model: "Intel Core i7", cores: 8, loadAvg: [0.5, 0.4, 0.3], usagePercent: 12 },
|
|
memory: { totalGB: 32, usedGB: 12.5, freeGB: 19.5, usagePercent: 39 },
|
|
disk: { totalGB: 500, usedGB: 220, freeGB: 280, usagePercent: 44 },
|
|
};
|
|
}
|
|
|
|
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 request(route, { method = "GET", auth, body, headers: extra } = {}) {
|
|
const headers = { ...extra };
|
|
if (auth) headers.Authorization = auth;
|
|
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
const res = await fetch(`${baseUrl}${route}`, { method, headers, body });
|
|
const parsed = await res.json().catch(() => ({}));
|
|
return { status: res.status, body: parsed };
|
|
}
|
|
|
|
function ingest(route, { auth = `Bearer ${INGEST_TOKEN}`, body = JSON.stringify(validSnapshot()), headers } = {}) {
|
|
return request(route, { method: "POST", auth, body, headers });
|
|
}
|
|
|
|
// --- #14 helpers ------------------------------------------------------------
|
|
|
|
// MAX_BODY_BYTES / MAX_STRING_LENGTH are private to index.js on purpose; the
|
|
// tests restate them so a silent change to either constant turns the matrix red
|
|
// instead of quietly re-drawing the ceiling.
|
|
const MAX_BODY_BYTES = 4096;
|
|
const MAX_STRING_LENGTH = 128;
|
|
|
|
// Fixed instant used by every freshness assertion. Snapshots are dated relative
|
|
// to it and Date.now() is pinned to it for the duration of the request, so
|
|
// `ageSeconds` is exact rather than "roughly N, unless the machine is busy".
|
|
const FROZEN_NOW = Date.UTC(2026, 7, 16, 12, 0, 0);
|
|
|
|
async function restartServer() {
|
|
await stopServer();
|
|
await startServer();
|
|
}
|
|
|
|
async function restartWithAllowlist(ids) {
|
|
process.env.HOSTS_ALLOWED_IDS = ids;
|
|
await restartServer();
|
|
}
|
|
|
|
function snapshotPath(id) {
|
|
return path.join(hostsDir, `${id}.json`);
|
|
}
|
|
|
|
function snapshotExists(id) {
|
|
return fs.existsSync(snapshotPath(id));
|
|
}
|
|
|
|
function readSnapshotFile(id) {
|
|
return fs.readFileSync(snapshotPath(id), "utf-8");
|
|
}
|
|
|
|
// Write a snapshot straight to disk, bypassing the ingestion path. Used by the
|
|
// freshness and degradation tests, which need a controlled receivedAt (and, for
|
|
// the degradation cases, files the API would never have produced itself).
|
|
function writeRawSnapshot(id, raw) {
|
|
fs.mkdirSync(hostsDir, { recursive: true });
|
|
fs.writeFileSync(snapshotPath(id), raw);
|
|
}
|
|
|
|
function writeSnapshotAged(id, ageSeconds, overrides = {}) {
|
|
const record = {
|
|
id,
|
|
...validSnapshot(),
|
|
receivedAt: new Date(FROZEN_NOW - ageSeconds * 1000).toISOString(),
|
|
...overrides,
|
|
};
|
|
writeRawSnapshot(id, JSON.stringify(record));
|
|
return record;
|
|
}
|
|
|
|
// Run a request with Date.now() pinned to FROZEN_NOW. Only Date.now is replaced
|
|
// (not the whole timer stack), so fetch and the http server keep their real
|
|
// timeouts; the server reads the clock once, in handleHostsList.
|
|
async function frozenRequest(route, options = {}) {
|
|
const realNow = Date.now;
|
|
Date.now = () => FROZEN_NOW;
|
|
try {
|
|
return await request(route, { auth: `Bearer ${TOKEN}`, ...options });
|
|
} finally {
|
|
Date.now = realNow;
|
|
}
|
|
}
|
|
|
|
// A JSON body of exactly `bytes` bytes (ASCII only, so bytes === characters),
|
|
// built around a payload that would otherwise be accepted. Lets the 4 KiB
|
|
// ceiling be pinned from both sides instead of "somewhere around 4 KiB".
|
|
function bodyOfExactly(bytes) {
|
|
const base = { ...validSnapshot(), padding: "" };
|
|
const overhead = JSON.stringify(base).length;
|
|
return JSON.stringify({ ...base, padding: "x".repeat(bytes - overhead) });
|
|
}
|
|
|
|
// The 413 is written and the socket is then destroyed, so a client that loses
|
|
// that race sees a transport error instead of a status. Both outcomes mean the
|
|
// ceiling fired; what matters is the assertion on the filesystem afterwards.
|
|
async function ingestOversized(route, body) {
|
|
try {
|
|
const { status } = await ingest(route, { body });
|
|
return status;
|
|
} catch (err) {
|
|
// A dead socket is NOT a 413. The whole client contract on this path is
|
|
// "you get a status code, not a transport error" — so surface the failure
|
|
// instead of folding it into the expected value. Returning 413 here would
|
|
// keep these tests green even if the server stopped writing the status and
|
|
// merely destroyed the connection.
|
|
return `transport error instead of a status: ${err.cause?.code || err.code || err.message}`;
|
|
}
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-hosts-test-"));
|
|
hostsDir = path.join(tmpDir, "hosts");
|
|
|
|
process.env.HEALTH_TOKEN = TOKEN;
|
|
process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN;
|
|
process.env.HOSTS_DIR = hostsDir;
|
|
process.env.HOSTS_ALLOWED_IDS = "thinkpad";
|
|
delete process.env.HOSTS_STALE_SECONDS;
|
|
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: a rejected /health request must never reach the real IdP. If
|
|
// the auth gate ever fails open, this errors out fast instead of hitting
|
|
// production (same guard as auth.test.js).
|
|
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 });
|
|
delete process.env.HOSTS_INGEST_TOKEN;
|
|
delete process.env.HOSTS_DIR;
|
|
delete process.env.HOSTS_ALLOWED_IDS;
|
|
delete process.env.HOSTS_STALE_SECONDS;
|
|
});
|
|
|
|
describe("POST /hosts/<id> — routing is the traversal control", () => {
|
|
// Every one of these must be 404, not 400/403: the request never reaches a
|
|
// handler. URL() normalises the traversal away, the rest fail the regex.
|
|
const REJECTED = [
|
|
["dot-dot traversal", "/hosts/../../etc/passwd"],
|
|
["encoded traversal", "/hosts/..%2f..%2fetc%2fpasswd"],
|
|
["uppercase id", "/hosts/THINKPAD"],
|
|
["id with a slash", "/hosts/thinkpad/extra"],
|
|
["empty id", "/hosts/"],
|
|
// Added by #14 — same rule, wider net.
|
|
["fully encoded dots", "/hosts/%2e%2e%2f%2e%2e%2fetc%2fpasswd"],
|
|
["trailing slash", "/hosts/thinkpad/"],
|
|
["double slash", "/hosts//thinkpad"],
|
|
["uppercase path segment", "/HOSTS/thinkpad"],
|
|
// Percent-encoding is NOT decoded before the match: `%64` stays `%64`, so
|
|
// an allowlisted id cannot be smuggled in through an encoded spelling.
|
|
["percent-encoded allowlisted id", "/hosts/thinkpa%64"],
|
|
["null byte", "/hosts/thinkpad%00"],
|
|
["file extension", "/hosts/thinkpad.json"],
|
|
["leading dash", "/hosts/-thinkpad"],
|
|
["underscore", "/hosts/think_pad"],
|
|
["dot in the id", "/hosts/think.pad"],
|
|
["absolute path as id", "/hosts//etc/passwd"],
|
|
];
|
|
|
|
test.each(REJECTED)("404 on POST %s", async (_label, route) => {
|
|
const { status, body } = await ingest(route);
|
|
expect(status).toBe(404);
|
|
expect(body.error).toBe("Not found");
|
|
});
|
|
|
|
test("404 on GET /hosts/thinkpad — the id route is POST-only", async () => {
|
|
const { status } = await request("/hosts/thinkpad", { auth: `Bearer ${TOKEN}` });
|
|
expect(status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe("POST /hosts/<id> — auth gate", () => {
|
|
test("401 with no Authorization header", async () => {
|
|
const { status, body } = await ingest("/hosts/thinkpad", { auth: null });
|
|
expect(status).toBe(401);
|
|
expect(body.error).toBe("Unauthorized");
|
|
});
|
|
|
|
test("401 when presenting the read token instead of the ingest token", async () => {
|
|
const { status } = await ingest("/hosts/thinkpad", { auth: `Bearer ${TOKEN}` });
|
|
expect(status).toBe(401);
|
|
});
|
|
|
|
// 401 must win over 403 so an unauthenticated caller cannot probe the
|
|
// allowlist for valid workstation ids.
|
|
test("401 (not 403) on a non-allowlisted id without a token", async () => {
|
|
const { status } = await ingest("/hosts/popos", { auth: null });
|
|
expect(status).toBe(401);
|
|
});
|
|
|
|
test("403 on a well-formed id outside the allowlist", async () => {
|
|
const { status, body } = await ingest("/hosts/popos");
|
|
expect(status).toBe(403);
|
|
expect(body.error).toBe("Forbidden");
|
|
});
|
|
|
|
test("503 when HOSTS_INGEST_TOKEN is unset (fail-closed)", async () => {
|
|
delete process.env.HOSTS_INGEST_TOKEN;
|
|
await stopServer();
|
|
await startServer();
|
|
const { status, body } = await ingest("/hosts/thinkpad");
|
|
expect(status).toBe(503);
|
|
expect(body.error).toBe("HOSTS_INGEST_TOKEN not configured");
|
|
});
|
|
});
|
|
|
|
describe("POST /hosts/<id> — body handling", () => {
|
|
test("204 and an atomically written file on a valid snapshot", async () => {
|
|
const { status } = await ingest("/hosts/thinkpad");
|
|
expect(status).toBe(204);
|
|
const written = JSON.parse(fs.readFileSync(path.join(hostsDir, "thinkpad.json"), "utf-8"));
|
|
expect(written.hostname).toBe("thinkpad-x1");
|
|
expect(typeof written.receivedAt).toBe("string");
|
|
// No leftover temp file next to the snapshot.
|
|
expect(fs.readdirSync(hostsDir)).toEqual(["thinkpad.json"]);
|
|
});
|
|
|
|
test("the persisted object is rebuilt from the whitelist, not stored verbatim", async () => {
|
|
const raw = `{"__proto__":{"polluted":true},"extra":"nope","receivedAt":"1999-01-01T00:00:00.000Z",${JSON.stringify(
|
|
{ ...validSnapshot(), hostname: "h".repeat(300) },
|
|
).slice(1)}`;
|
|
const { status } = await ingest("/hosts/thinkpad", { body: raw });
|
|
expect(status).toBe(204);
|
|
|
|
const written = JSON.parse(fs.readFileSync(path.join(hostsDir, "thinkpad.json"), "utf-8"));
|
|
expect(Object.keys(written)).toEqual([
|
|
"id",
|
|
"hostname",
|
|
"uptime",
|
|
"cpu",
|
|
"memory",
|
|
"disk",
|
|
"receivedAt",
|
|
]);
|
|
expect(written.extra).toBeUndefined();
|
|
expect(written.polluted).toBeUndefined();
|
|
expect({}.polluted).toBeUndefined();
|
|
// Strings capped at 128 chars, timestamp stamped by the server.
|
|
expect(written.hostname.length).toBe(128);
|
|
expect(written.receivedAt).not.toBe("1999-01-01T00:00:00.000Z");
|
|
expect(Date.now() - new Date(written.receivedAt).getTime()).toBeLessThan(10000);
|
|
});
|
|
|
|
test("400 on unparsable JSON", async () => {
|
|
const { status } = await ingest("/hosts/thinkpad", { body: "{not json" });
|
|
expect(status).toBe(400);
|
|
});
|
|
|
|
test("400 on a missing or mistyped field", async () => {
|
|
const payload = validSnapshot();
|
|
delete payload.memory;
|
|
const { status, body } = await ingest("/hosts/thinkpad", { body: JSON.stringify(payload) });
|
|
expect(status).toBe(400);
|
|
expect(body.error).toMatch(/memory/);
|
|
expect(fs.existsSync(path.join(hostsDir, "thinkpad.json"))).toBe(false);
|
|
});
|
|
|
|
test("413 past 4 KiB, and nothing is written", async () => {
|
|
const payload = { ...validSnapshot(), padding: "x".repeat(5000) };
|
|
let status;
|
|
try {
|
|
({ status } = await ingest("/hosts/thinkpad", { body: JSON.stringify(payload) }));
|
|
} catch {
|
|
// The socket is destroyed right after the 413 flushes; a client that
|
|
// loses the race sees a transport error instead. Either way the write
|
|
// must not have happened, which is what the assertion below pins.
|
|
status = 413;
|
|
}
|
|
expect(status).toBe(413);
|
|
expect(fs.existsSync(path.join(hostsDir, "thinkpad.json"))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("GET /hosts", () => {
|
|
test("401 when presenting the ingest token instead of the read token", async () => {
|
|
const { status } = await request("/hosts", { auth: `Bearer ${INGEST_TOKEN}` });
|
|
expect(status).toBe(401);
|
|
});
|
|
|
|
test("reports an allowlisted host that never checked in", async () => {
|
|
const { status, body } = await request("/hosts", { auth: `Bearer ${TOKEN}` });
|
|
expect(status).toBe(200);
|
|
expect(body.staleAfterSeconds).toBe(900);
|
|
expect(body.hosts).toHaveLength(1);
|
|
expect(body.hosts[0]).toMatchObject({ id: "thinkpad", neverSeen: true, online: false });
|
|
});
|
|
|
|
test("serves the snapshot back with a server-computed freshness", async () => {
|
|
await ingest("/hosts/thinkpad");
|
|
const { status, body } = await request("/hosts", { auth: `Bearer ${TOKEN}` });
|
|
expect(status).toBe(200);
|
|
const host = body.hosts[0];
|
|
expect(host.neverSeen).toBeUndefined();
|
|
expect(host.hostname).toBe("thinkpad-x1");
|
|
expect(host.ageSeconds).toBeLessThan(5);
|
|
expect(host.online).toBe(true);
|
|
expect(Object.keys(host.cpu)).toEqual(["model", "cores", "loadAvg", "usagePercent"]);
|
|
});
|
|
|
|
test("online flips to false past HOSTS_STALE_SECONDS", async () => {
|
|
fs.mkdirSync(hostsDir, { recursive: true });
|
|
const stale = {
|
|
id: "thinkpad",
|
|
...validSnapshot(),
|
|
receivedAt: new Date(Date.now() - 3600 * 1000).toISOString(),
|
|
};
|
|
fs.writeFileSync(path.join(hostsDir, "thinkpad.json"), JSON.stringify(stale));
|
|
|
|
const { body } = await request("/hosts", { auth: `Bearer ${TOKEN}` });
|
|
expect(body.hosts[0].online).toBe(false);
|
|
expect(body.hosts[0].ageSeconds).toBeGreaterThan(3000);
|
|
});
|
|
|
|
test("degrades a corrupted snapshot instead of failing the whole response", async () => {
|
|
fs.mkdirSync(hostsDir, { recursive: true });
|
|
fs.writeFileSync(path.join(hostsDir, "thinkpad.json"), "{ truncated");
|
|
|
|
const { status, body } = await request("/hosts", { auth: `Bearer ${TOKEN}` });
|
|
expect(status).toBe(200);
|
|
expect(body.hosts).toHaveLength(1);
|
|
expect(body.hosts[0].neverSeen).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("HOSTS_ALLOWED_IDS validation", () => {
|
|
test("entries that are not valid host ids are dropped at startup", async () => {
|
|
process.env.HOSTS_ALLOWED_IDS = "thinkpad, ../defenseurs/status ,POPOS,,popos";
|
|
await stopServer();
|
|
await startServer();
|
|
|
|
const { body } = await request("/hosts", { auth: `Bearer ${TOKEN}` });
|
|
expect(body.hosts.map((h) => h.id)).toEqual(["popos", "thinkpad"]);
|
|
});
|
|
|
|
test("an unset allowlist falls back to the single default id", async () => {
|
|
await restartWithAllowlist("");
|
|
const { body } = await request("/hosts", { auth: `Bearer ${TOKEN}` });
|
|
expect(body.hosts.map((h) => h.id)).toEqual(["thinkpad"]);
|
|
});
|
|
|
|
test("an allowlist of only invalid entries admits nobody", async () => {
|
|
await restartWithAllowlist(", ,POPOS,../etc");
|
|
const { body } = await request("/hosts", { auth: `Bearer ${TOKEN}` });
|
|
expect(body.hosts).toEqual([]);
|
|
// And the ingestion path closes with it: nothing is allowlisted, so a
|
|
// well-formed id is now 403 rather than accepted by default.
|
|
const { status } = await ingest("/hosts/thinkpad");
|
|
expect(status).toBe(403);
|
|
});
|
|
|
|
test("a repeated id yields a single entry", async () => {
|
|
await restartWithAllowlist("thinkpad,thinkpad, thinkpad ");
|
|
const { body } = await request("/hosts", { auth: `Bearer ${TOKEN}` });
|
|
expect(body.hosts.map((h) => h.id)).toEqual(["thinkpad"]);
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// Issue #14 — exhaustive matrix
|
|
// ============================================================================
|
|
|
|
describe("POST /hosts/<id> — the ingestion token, near-miss by near-miss", () => {
|
|
const BAD_AUTH = [
|
|
["a wrong token", "Bearer wrong-ingest-token"],
|
|
["the scheme with no token", "Bearer"],
|
|
["a lowercase scheme", `bearer ${INGEST_TOKEN}`],
|
|
["an uppercase scheme", `BEARER ${INGEST_TOKEN}`],
|
|
["the raw token with no scheme", INGEST_TOKEN],
|
|
["a Basic scheme carrying the token", `Basic ${Buffer.from(`x:${INGEST_TOKEN}`).toString("base64")}`],
|
|
["an extra space between scheme and token", `Bearer ${INGEST_TOKEN}`],
|
|
["a prefix of the real token", `Bearer ${INGEST_TOKEN.slice(0, -1)}`],
|
|
["the real token with one character appended", `Bearer ${INGEST_TOKEN}x`],
|
|
];
|
|
|
|
test.each(BAD_AUTH)("401 with %s", async (_label, header) => {
|
|
const { status, body } = await ingest("/hosts/thinkpad", { auth: header });
|
|
expect(status).toBe(401);
|
|
expect(body.error).toBe("Unauthorized");
|
|
expect(snapshotExists("thinkpad")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("POST /hosts/<id> — fail-closed when HOSTS_INGEST_TOKEN is unset", () => {
|
|
beforeEach(async () => {
|
|
delete process.env.HOSTS_INGEST_TOKEN;
|
|
await restartServer();
|
|
});
|
|
|
|
test("503 with no Authorization header at all", async () => {
|
|
const { status, body } = await ingest("/hosts/thinkpad", { auth: null });
|
|
expect(status).toBe(503);
|
|
expect(body.error).toBe("HOSTS_INGEST_TOKEN not configured");
|
|
});
|
|
|
|
// Auth runs before the allowlist check, so a misconfigured deployment reports
|
|
// its own fault rather than blaming the caller's id.
|
|
test("503 wins over the 403 a non-allowlisted id would otherwise get", async () => {
|
|
const { status, body } = await ingest("/hosts/popos");
|
|
expect(status).toBe(503);
|
|
expect(body.error).toBe("HOSTS_INGEST_TOKEN not configured");
|
|
});
|
|
|
|
test("404 still wins for a malformed id — routing runs before auth", async () => {
|
|
const { status, body } = await ingest("/hosts/THINKPAD");
|
|
expect(status).toBe(404);
|
|
expect(body.error).toBe("Not found");
|
|
});
|
|
|
|
test("the read routes are unaffected — the two tokens are independent", async () => {
|
|
const { status } = await request("/hosts", { auth: `Bearer ${TOKEN}` });
|
|
expect(status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe("the read token and the ingestion token are independent", () => {
|
|
test("ingestion still works when HEALTH_TOKEN is unset", async () => {
|
|
delete process.env.HEALTH_TOKEN;
|
|
await restartServer();
|
|
const { status } = await ingest("/hosts/thinkpad");
|
|
expect(status).toBe(204);
|
|
});
|
|
|
|
test("the read routes fail closed when HEALTH_TOKEN is unset", async () => {
|
|
delete process.env.HEALTH_TOKEN;
|
|
await restartServer();
|
|
const { status, body } = await request("/hosts", { auth: `Bearer ${TOKEN}` });
|
|
expect(status).toBe(401);
|
|
expect(body.error).toBe("HEALTH_TOKEN not configured");
|
|
});
|
|
});
|
|
|
|
describe("the ingestion token opens nothing on the read side", () => {
|
|
// The write token lives on every workstation, i.e. on the least trusted
|
|
// machines in the parc. It must not read the VPS metrics or the Defenseurs
|
|
// reports back.
|
|
const READ_ROUTES = [
|
|
"/health",
|
|
"/defenseurs",
|
|
"/defenseurs/findings",
|
|
"/reports/scans",
|
|
"/hosts",
|
|
];
|
|
|
|
test.each(READ_ROUTES)("401 on GET %s with the ingest token", async (route) => {
|
|
const { status, body } = await request(route, { auth: `Bearer ${INGEST_TOKEN}` });
|
|
expect(status).toBe(401);
|
|
expect(body.error).toBe("Unauthorized");
|
|
});
|
|
});
|
|
|
|
describe("POST /hosts/<id> — route boundaries", () => {
|
|
test("a 32-character id is well formed: 403, not 404", async () => {
|
|
const { status, body } = await ingest(`/hosts/${"a".repeat(32)}`);
|
|
expect(status).toBe(403);
|
|
expect(body.error).toBe("Forbidden");
|
|
});
|
|
|
|
test("a 33-character id fails the route: 404, not 403", async () => {
|
|
const { status, body } = await ingest(`/hosts/${"a".repeat(33)}`);
|
|
expect(status).toBe(404);
|
|
expect(body.error).toBe("Not found");
|
|
});
|
|
|
|
test.each(["GET", "PUT", "PATCH", "DELETE"])("404 on %s /hosts/thinkpad", async (method) => {
|
|
const { status } = await request("/hosts/thinkpad", { method, auth: `Bearer ${INGEST_TOKEN}` });
|
|
expect(status).toBe(404);
|
|
});
|
|
|
|
test("404 on POST /hosts — the collection is read-only", async () => {
|
|
const { status, body } = await ingest("/hosts");
|
|
expect(status).toBe(404);
|
|
expect(body.error).toBe("Not found");
|
|
});
|
|
|
|
test("a query string is not part of the id", async () => {
|
|
const { status } = await ingest("/hosts/thinkpad?debug=1");
|
|
expect(status).toBe(204);
|
|
expect(snapshotExists("thinkpad")).toBe(true);
|
|
});
|
|
|
|
// 404 leaks nothing about the token or the allowlist: a malformed id answers
|
|
// the same whether the caller is authenticated, wrong, or silent.
|
|
const UNAUTH = [
|
|
["no Authorization header", null],
|
|
["a wrong token", "Bearer wrong"],
|
|
["the read token", `Bearer ${TOKEN}`],
|
|
["the valid ingest token", `Bearer ${INGEST_TOKEN}`],
|
|
];
|
|
|
|
test.each(UNAUTH)("404 on a malformed id with %s", async (_label, auth) => {
|
|
const { status, body } = await ingest("/hosts/../../etc/passwd", { auth });
|
|
expect(status).toBe(404);
|
|
expect(body.error).toBe("Not found");
|
|
});
|
|
});
|
|
|
|
describe("rejections leave an audit trail", () => {
|
|
let warn;
|
|
|
|
beforeEach(() => {
|
|
warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
warn.mockRestore();
|
|
});
|
|
|
|
function lastLine() {
|
|
return warn.mock.calls.at(-1)[0];
|
|
}
|
|
|
|
test("a 401 on the ingestion path is logged with its route", async () => {
|
|
await ingest("/hosts/thinkpad", { auth: "Bearer wrong" });
|
|
const line = lastLine();
|
|
expect(line).toContain("[auth] 401 POST /hosts/thinkpad");
|
|
expect(line).toContain("id=thinkpad");
|
|
expect(line).toContain("reason=missing or invalid bearer token");
|
|
});
|
|
|
|
test("a 403 names the id that was refused", async () => {
|
|
await ingest("/hosts/popos");
|
|
const line = lastLine();
|
|
expect(line).toContain("[auth] 403 POST /hosts/popos");
|
|
expect(line).toContain("id=popos");
|
|
});
|
|
|
|
test("a 404 is not logged — it never reached the auth gate", async () => {
|
|
await ingest("/hosts/THINKPAD");
|
|
expect(warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// The log line is assembled from caller-controlled values (X-Real-IP among
|
|
// them), so it must stay on one line and inside printable ASCII: a crafted
|
|
// header must not be able to forge an extra log record.
|
|
test("a hostile X-Real-IP cannot break out of its log line", async () => {
|
|
await ingest("/hosts/thinkpad", {
|
|
auth: "Bearer wrong",
|
|
headers: { "X-Real-IP": `1.2.3.4éé${"A".repeat(200)}` },
|
|
});
|
|
const line = lastLine();
|
|
expect(line).not.toMatch(/[\r\n]/);
|
|
expect(line).toMatch(/^[\x20-\x7e]+$/);
|
|
const ip = /ip=(\S+) /.exec(line)[1];
|
|
expect(ip.length).toBeLessThanOrEqual(64);
|
|
});
|
|
});
|
|
|
|
describe("POST /hosts/<id> — bodies that are not a JSON object", () => {
|
|
const INVALID_BODIES = [
|
|
["unparsable JSON", "{not json", /valid JSON/],
|
|
["an empty body", "", /valid JSON/],
|
|
["a truncated object", '{"hostname":"x"', /valid JSON/],
|
|
["a JSON array", "[]", /body must be a JSON object/],
|
|
["JSON null", "null", /body must be a JSON object/],
|
|
["a bare JSON string", '"thinkpad"', /body must be a JSON object/],
|
|
["a bare JSON number", "42", /body must be a JSON object/],
|
|
["a bare JSON boolean", "true", /body must be a JSON object/],
|
|
];
|
|
|
|
test.each(INVALID_BODIES)("400 on %s", async (_label, body, expected) => {
|
|
const { status, body: parsed } = await ingest("/hosts/thinkpad", { body });
|
|
expect(status).toBe(400);
|
|
expect(parsed.error).toMatch(expected);
|
|
expect(snapshotExists("thinkpad")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("POST /hosts/<id> — field validation matrix", () => {
|
|
// Each row mutates an otherwise valid snapshot. The expected fragment pins
|
|
// that the error names the offending field: the workstation agent is
|
|
// debugged from this message alone.
|
|
const INVALID_FIELDS = [
|
|
["hostname missing", (p) => delete p.hostname, /hostname must be a string/],
|
|
["hostname as a number", (p) => (p.hostname = 42), /hostname must be a string/],
|
|
["hostname as an object", (p) => (p.hostname = { name: "x" }), /hostname must be a string/],
|
|
["hostname as null", (p) => (p.hostname = null), /hostname must be a string/],
|
|
["uptime missing", (p) => delete p.uptime, /uptime must be a finite number/],
|
|
["uptime as a numeric string", (p) => (p.uptime = "4242"), /uptime must be a finite number/],
|
|
["cpu missing", (p) => delete p.cpu, /cpu must be an object/],
|
|
["cpu as an array", (p) => (p.cpu = []), /cpu must be an object/],
|
|
["cpu as null", (p) => (p.cpu = null), /cpu must be an object/],
|
|
["cpu.model missing", (p) => delete p.cpu.model, /cpu\.model must be a string/],
|
|
["cpu.cores as a string", (p) => (p.cpu.cores = "8"), /cpu\.cores must be a finite number/],
|
|
["cpu.usagePercent missing", (p) => delete p.cpu.usagePercent, /cpu\.usagePercent must be a finite number/],
|
|
["cpu.loadAvg missing", (p) => delete p.cpu.loadAvg, /cpu\.loadAvg must be an array/],
|
|
["cpu.loadAvg as a string", (p) => (p.cpu.loadAvg = "0.5"), /cpu\.loadAvg must be an array/],
|
|
["cpu.loadAvg holding a string", (p) => (p.cpu.loadAvg = [0.5, "x", 0.3]), /cpu\.loadAvg must contain finite numbers/],
|
|
["memory missing", (p) => delete p.memory, /memory must be an object/],
|
|
["memory as an array", (p) => (p.memory = []), /memory must be an object/],
|
|
["memory.usedGB missing", (p) => delete p.memory.usedGB, /memory\.usedGB must be a finite number/],
|
|
["memory.usagePercent as a string", (p) => (p.memory.usagePercent = "39"), /memory\.usagePercent must be a finite number/],
|
|
["disk missing", (p) => delete p.disk, /disk must be an object/],
|
|
["disk.totalGB missing", (p) => delete p.disk.totalGB, /disk\.totalGB must be a finite number/],
|
|
["disk.freeGB as null", (p) => (p.disk.freeGB = null), /disk\.freeGB must be a finite number/],
|
|
];
|
|
|
|
test.each(INVALID_FIELDS)("400 on %s", async (_label, mutate, expected) => {
|
|
const payload = validSnapshot();
|
|
mutate(payload);
|
|
const { status, body } = await ingest("/hosts/thinkpad", { body: JSON.stringify(payload) });
|
|
expect(status).toBe(400);
|
|
expect(body.error).toMatch(expected);
|
|
expect(snapshotExists("thinkpad")).toBe(false);
|
|
});
|
|
|
|
// JSON.stringify() cannot even express these, so they need a raw body: a
|
|
// hand-rolled agent (or a hostile one) can put them on the wire.
|
|
test("400 on an overflowing number that JSON.parse turns into Infinity", async () => {
|
|
const raw = JSON.stringify(validSnapshot()).replace('"uptime":4242', '"uptime":1e999');
|
|
const { status, body } = await ingest("/hosts/thinkpad", { body: raw });
|
|
expect(status).toBe(400);
|
|
expect(body.error).toMatch(/uptime must be a finite number/);
|
|
expect(snapshotExists("thinkpad")).toBe(false);
|
|
});
|
|
|
|
test("a duplicate key keeps the last value and is still validated", async () => {
|
|
const raw = `{"hostname":"first",${JSON.stringify({ ...validSnapshot(), hostname: 42 }).slice(1)}`;
|
|
const { status, body } = await ingest("/hosts/thinkpad", { body: raw });
|
|
expect(status).toBe(400);
|
|
expect(body.error).toMatch(/hostname must be a string/);
|
|
});
|
|
|
|
test("uptime of 0 is a legitimate value, not a missing field", async () => {
|
|
const { status } = await ingest("/hosts/thinkpad", {
|
|
body: JSON.stringify({ ...validSnapshot(), uptime: 0 }),
|
|
});
|
|
expect(status).toBe(204);
|
|
expect(JSON.parse(readSnapshotFile("thinkpad")).uptime).toBe(0);
|
|
});
|
|
|
|
test("an empty loadAvg array is accepted", async () => {
|
|
const payload = validSnapshot();
|
|
payload.cpu.loadAvg = [];
|
|
const { status } = await ingest("/hosts/thinkpad", { body: JSON.stringify(payload) });
|
|
expect(status).toBe(204);
|
|
expect(JSON.parse(readSnapshotFile("thinkpad")).cpu.loadAvg).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("POST /hosts/<id> — the 4 KiB ceiling", () => {
|
|
test("an 8 KiB body is refused and nothing is written", async () => {
|
|
const body = JSON.stringify({ ...validSnapshot(), padding: "x".repeat(8192) });
|
|
expect(await ingestOversized("/hosts/thinkpad", body)).toBe(413);
|
|
expect(snapshotExists("thinkpad")).toBe(false);
|
|
});
|
|
|
|
test("a body of exactly 4096 bytes is accepted", async () => {
|
|
const body = bodyOfExactly(MAX_BODY_BYTES);
|
|
expect(Buffer.byteLength(body)).toBe(MAX_BODY_BYTES);
|
|
const { status } = await ingest("/hosts/thinkpad", { body });
|
|
expect(status).toBe(204);
|
|
// The padding was only there to reach the ceiling — it is not a whitelisted
|
|
// field, so it must not survive into the file.
|
|
expect(readSnapshotFile("thinkpad")).not.toContain("padding");
|
|
});
|
|
|
|
test("one byte past the ceiling is refused", async () => {
|
|
expect(await ingestOversized("/hosts/thinkpad", bodyOfExactly(MAX_BODY_BYTES + 1))).toBe(413);
|
|
expect(snapshotExists("thinkpad")).toBe(false);
|
|
});
|
|
|
|
// The literal reading of the acceptance criterion: a 4 KiB hostname never
|
|
// even reaches the sanitiser, because the body carrying it is already over
|
|
// the ceiling. The truncation control is pinned separately below, with the
|
|
// largest hostname that does fit.
|
|
test("a 4 KiB hostname is stopped by the ceiling, not by the sanitiser", async () => {
|
|
const body = JSON.stringify({ ...validSnapshot(), hostname: "H".repeat(4096) });
|
|
expect(await ingestOversized("/hosts/thinkpad", body)).toBe(413);
|
|
expect(snapshotExists("thinkpad")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("POST /hosts/<id> — what actually lands on disk", () => {
|
|
test("a hostname that fits under the ceiling is truncated to 128 characters", async () => {
|
|
const body = JSON.stringify({ ...validSnapshot(), hostname: "H".repeat(3500) });
|
|
const { status } = await ingest("/hosts/thinkpad", { body });
|
|
expect(status).toBe(204);
|
|
|
|
const raw = readSnapshotFile("thinkpad");
|
|
expect(JSON.parse(raw).hostname).toBe("H".repeat(MAX_STRING_LENGTH));
|
|
// Not merely "shorter": the oversized value is nowhere in the file.
|
|
expect(raw).not.toContain("H".repeat(MAX_STRING_LENGTH + 1));
|
|
});
|
|
|
|
test("cpu.model is capped the same way", async () => {
|
|
const payload = validSnapshot();
|
|
payload.cpu.model = "M".repeat(1000);
|
|
await ingest("/hosts/thinkpad", { body: JSON.stringify(payload) });
|
|
expect(JSON.parse(readSnapshotFile("thinkpad")).cpu.model.length).toBe(MAX_STRING_LENGTH);
|
|
});
|
|
|
|
test("loadAvg keeps only the first three entries", async () => {
|
|
const payload = validSnapshot();
|
|
payload.cpu.loadAvg = [1, 2, 3, 4, 5];
|
|
await ingest("/hosts/thinkpad", { body: JSON.stringify(payload) });
|
|
expect(JSON.parse(readSnapshotFile("thinkpad")).cpu.loadAvg).toEqual([1, 2, 3]);
|
|
});
|
|
|
|
test("__proto__ appears nowhere in the file text", async () => {
|
|
const raw = `{"__proto__":{"polluted":true},${JSON.stringify(validSnapshot()).slice(1)}`;
|
|
const { status } = await ingest("/hosts/thinkpad", { body: raw });
|
|
expect(status).toBe(204);
|
|
expect(readSnapshotFile("thinkpad")).not.toContain("__proto__");
|
|
expect({}.polluted).toBeUndefined();
|
|
});
|
|
|
|
test("constructor and prototype keys are dropped like any other unknown key", async () => {
|
|
const raw = `{"constructor":{"x":1},"prototype":{"y":2},${JSON.stringify(validSnapshot()).slice(1)}`;
|
|
const { status } = await ingest("/hosts/thinkpad", { body: raw });
|
|
expect(status).toBe(204);
|
|
const written = readSnapshotFile("thinkpad");
|
|
expect(written).not.toContain("prototype");
|
|
expect(written).not.toContain('"constructor"');
|
|
});
|
|
|
|
test("the route id wins over an id supplied in the body", async () => {
|
|
const body = JSON.stringify({ ...validSnapshot(), id: "popos" });
|
|
await ingest("/hosts/thinkpad", { body });
|
|
expect(JSON.parse(readSnapshotFile("thinkpad")).id).toBe("thinkpad");
|
|
expect(snapshotExists("popos")).toBe(false);
|
|
});
|
|
|
|
test("the snapshot file is created readable by its owner only", async () => {
|
|
await ingest("/hosts/thinkpad");
|
|
expect(fs.statSync(snapshotPath("thinkpad")).mode & 0o777).toBe(0o600);
|
|
});
|
|
|
|
test("HOSTS_DIR is created on demand, nested levels included", async () => {
|
|
hostsDir = path.join(tmpDir, "deep", "nested", "hosts");
|
|
process.env.HOSTS_DIR = hostsDir;
|
|
await restartServer();
|
|
const { status } = await ingest("/hosts/thinkpad");
|
|
expect(status).toBe(204);
|
|
expect(snapshotExists("thinkpad")).toBe(true);
|
|
});
|
|
|
|
test("a second POST replaces the first, leaving one file and no temp debris", async () => {
|
|
await ingest("/hosts/thinkpad");
|
|
const first = JSON.parse(readSnapshotFile("thinkpad"));
|
|
|
|
await ingest("/hosts/thinkpad", {
|
|
body: JSON.stringify({ ...validSnapshot(), hostname: "thinkpad-x1-reinstalled" }),
|
|
});
|
|
const second = JSON.parse(readSnapshotFile("thinkpad"));
|
|
|
|
expect(second.hostname).toBe("thinkpad-x1-reinstalled");
|
|
expect(new Date(second.receivedAt).getTime()).toBeGreaterThanOrEqual(
|
|
new Date(first.receivedAt).getTime(),
|
|
);
|
|
expect(fs.readdirSync(hostsDir)).toEqual(["thinkpad.json"]);
|
|
});
|
|
|
|
test("two workstations write to two files", async () => {
|
|
await restartWithAllowlist("thinkpad,popos");
|
|
await ingest("/hosts/thinkpad");
|
|
await ingest("/hosts/popos", {
|
|
body: JSON.stringify({ ...validSnapshot(), hostname: "popos-desktop" }),
|
|
});
|
|
|
|
expect(fs.readdirSync(hostsDir).sort()).toEqual(["popos.json", "thinkpad.json"]);
|
|
expect(JSON.parse(readSnapshotFile("popos")).hostname).toBe("popos-desktop");
|
|
expect(JSON.parse(readSnapshotFile("thinkpad")).hostname).toBe("thinkpad-x1");
|
|
});
|
|
|
|
// The API stores what it was given, within the cap. Escaping belongs to the
|
|
// consumer (the admin dashboard renders through React, which escapes); the
|
|
// point here is that the stored value is not silently mangled either.
|
|
test("markup in a hostname is stored verbatim, not escaped or stripped", async () => {
|
|
const hostile = "<img src=x onerror=alert(1)>";
|
|
await ingest("/hosts/thinkpad", {
|
|
body: JSON.stringify({ ...validSnapshot(), hostname: hostile }),
|
|
});
|
|
expect(JSON.parse(readSnapshotFile("thinkpad")).hostname).toBe(hostile);
|
|
});
|
|
|
|
test("500 when the snapshot cannot be persisted", async () => {
|
|
// HOSTS_DIR points at a regular file: mkdirSync throws, the handler must
|
|
// answer 500 rather than crash the process.
|
|
const blocker = path.join(tmpDir, "not-a-directory");
|
|
fs.writeFileSync(blocker, "");
|
|
process.env.HOSTS_DIR = blocker;
|
|
await restartServer();
|
|
|
|
const { status, body } = await ingest("/hosts/thinkpad");
|
|
expect(status).toBe(500);
|
|
expect(body.error).toBe("Internal error");
|
|
});
|
|
});
|
|
|
|
describe("GET /hosts — freshness boundary", () => {
|
|
const BOUNDARY = [
|
|
[0, true],
|
|
[1, true],
|
|
[899, true],
|
|
[900, true],
|
|
[901, false],
|
|
[3600, false],
|
|
];
|
|
|
|
test.each(BOUNDARY)("an age of %ss reports online=%s", async (ageSeconds, online) => {
|
|
writeSnapshotAged("thinkpad", ageSeconds);
|
|
const { status, body } = await frozenRequest("/hosts");
|
|
expect(status).toBe(200);
|
|
expect(body.hosts[0].ageSeconds).toBe(ageSeconds);
|
|
expect(body.hosts[0].online).toBe(online);
|
|
});
|
|
|
|
// A workstation whose clock runs ahead cannot report a negative age, and it
|
|
// gains nothing by trying: receivedAt is stamped by the server anyway.
|
|
test("a receivedAt in the future clamps to an age of 0", async () => {
|
|
writeSnapshotAged("thinkpad", -600);
|
|
const { body } = await frozenRequest("/hosts");
|
|
expect(body.hosts[0].ageSeconds).toBe(0);
|
|
expect(body.hosts[0].online).toBe(true);
|
|
});
|
|
|
|
test("ageSeconds is derived from receivedAt, which is echoed unchanged", async () => {
|
|
const record = writeSnapshotAged("thinkpad", 42);
|
|
const { body } = await frozenRequest("/hosts");
|
|
expect(body.hosts[0].receivedAt).toBe(record.receivedAt);
|
|
expect(body.hosts[0].ageSeconds).toBe(42);
|
|
});
|
|
});
|
|
|
|
describe("HOSTS_STALE_SECONDS", () => {
|
|
test("a custom threshold moves the boundary and is echoed to the client", async () => {
|
|
process.env.HOSTS_STALE_SECONDS = "60";
|
|
await restartServer();
|
|
writeSnapshotAged("thinkpad", 60);
|
|
|
|
const { body } = await frozenRequest("/hosts");
|
|
expect(body.staleAfterSeconds).toBe(60);
|
|
expect(body.hosts[0].online).toBe(true);
|
|
});
|
|
|
|
test("one second past a custom threshold is offline", async () => {
|
|
process.env.HOSTS_STALE_SECONDS = "60";
|
|
await restartServer();
|
|
writeSnapshotAged("thinkpad", 61);
|
|
|
|
const { body } = await frozenRequest("/hosts");
|
|
expect(body.hosts[0].online).toBe(false);
|
|
});
|
|
|
|
const BAD_THRESHOLDS = [
|
|
["unset", undefined],
|
|
["empty", ""],
|
|
["not a number", "abc"],
|
|
["zero", "0"],
|
|
["negative", "-5"],
|
|
];
|
|
|
|
test.each(BAD_THRESHOLDS)("falls back to 900 when %s", async (_label, value) => {
|
|
if (value === undefined) delete process.env.HOSTS_STALE_SECONDS;
|
|
else process.env.HOSTS_STALE_SECONDS = value;
|
|
await restartServer();
|
|
|
|
const { body } = await request("/hosts", { auth: `Bearer ${TOKEN}` });
|
|
expect(body.staleAfterSeconds).toBe(900);
|
|
});
|
|
});
|
|
|
|
describe("GET /hosts — listing semantics", () => {
|
|
test("one entry per allowlisted id, sorted, whatever their state", async () => {
|
|
await restartWithAllowlist("thinkpad,popos,pixel");
|
|
writeSnapshotAged("thinkpad", 10);
|
|
writeRawSnapshot("popos", "{ truncated");
|
|
|
|
const { body } = await frozenRequest("/hosts");
|
|
expect(body.hosts.map((h) => h.id)).toEqual(["pixel", "popos", "thinkpad"]);
|
|
// pixel never checked in, popos is corrupt — neither takes the healthy
|
|
// neighbour down with it.
|
|
expect(body.hosts[0].neverSeen).toBe(true);
|
|
expect(body.hosts[1].neverSeen).toBe(true);
|
|
expect(body.hosts[2]).toMatchObject({
|
|
hostname: "thinkpad-x1",
|
|
ageSeconds: 10,
|
|
online: true,
|
|
});
|
|
});
|
|
|
|
test("a snapshot file for a non-allowlisted id is never served", async () => {
|
|
writeSnapshotAged("popos", 10);
|
|
const { body } = await frozenRequest("/hosts");
|
|
expect(body.hosts.map((h) => h.id)).toEqual(["thinkpad"]);
|
|
expect(JSON.stringify(body)).not.toContain("popos");
|
|
});
|
|
|
|
test("a never-seen entry has an exact, stable shape", async () => {
|
|
const { body } = await request("/hosts", { auth: `Bearer ${TOKEN}` });
|
|
expect(body.hosts[0]).toEqual({
|
|
id: "thinkpad",
|
|
receivedAt: null,
|
|
ageSeconds: null,
|
|
online: false,
|
|
neverSeen: true,
|
|
});
|
|
});
|
|
|
|
test("a live entry has an exact, stable key set", async () => {
|
|
writeSnapshotAged("thinkpad", 5);
|
|
const { body } = await frozenRequest("/hosts");
|
|
expect(Object.keys(body.hosts[0])).toEqual([
|
|
"id",
|
|
"hostname",
|
|
"uptime",
|
|
"cpu",
|
|
"memory",
|
|
"disk",
|
|
"receivedAt",
|
|
"ageSeconds",
|
|
"online",
|
|
]);
|
|
});
|
|
|
|
// HOSTS_DIR is a writable bind-mount, so the file is re-validated on the way
|
|
// out rather than trusted because "we wrote it".
|
|
const DEGRADED = [
|
|
["an unparsable file", "{ truncated"],
|
|
["an empty file", ""],
|
|
["a JSON array", "[]"],
|
|
["JSON null", "null"],
|
|
["an off-shape snapshot", JSON.stringify({ id: "thinkpad", hostname: "x" })],
|
|
["a snapshot with no receivedAt", JSON.stringify({ id: "thinkpad", ...validSnapshot() })],
|
|
[
|
|
"a receivedAt that is not a date",
|
|
JSON.stringify({ id: "thinkpad", ...validSnapshot(), receivedAt: "yesterday" }),
|
|
],
|
|
[
|
|
"a receivedAt that is not a string",
|
|
JSON.stringify({ id: "thinkpad", ...validSnapshot(), receivedAt: 1700000000000 }),
|
|
],
|
|
];
|
|
|
|
test.each(DEGRADED)("degrades %s to a never-seen entry", async (_label, raw) => {
|
|
writeRawSnapshot("thinkpad", raw);
|
|
const { status, body } = await request("/hosts", { auth: `Bearer ${TOKEN}` });
|
|
expect(status).toBe(200);
|
|
expect(body.hosts).toHaveLength(1);
|
|
expect(body.hosts[0].neverSeen).toBe(true);
|
|
expect(body.hosts[0].online).toBe(false);
|
|
});
|
|
|
|
test("keys added to the file on disk are stripped on the way out", async () => {
|
|
writeSnapshotAged("thinkpad", 10, { evil: "<script>", receivedAtOverride: 1 });
|
|
const { body } = await frozenRequest("/hosts");
|
|
expect(JSON.stringify(body)).not.toContain("evil");
|
|
expect(body.hosts[0].evil).toBeUndefined();
|
|
});
|
|
|
|
test("an oversized hostname written straight to disk is truncated on the way out", async () => {
|
|
writeSnapshotAged("thinkpad", 10, { hostname: "H".repeat(4096) });
|
|
const { body } = await frozenRequest("/hosts");
|
|
expect(body.hosts[0].hostname.length).toBe(MAX_STRING_LENGTH);
|
|
});
|
|
|
|
test("a snapshot missing from a directory that does not exist is simply never seen", async () => {
|
|
process.env.HOSTS_DIR = path.join(tmpDir, "absent");
|
|
await restartServer();
|
|
const { status, body } = await request("/hosts", { auth: `Bearer ${TOKEN}` });
|
|
expect(status).toBe(200);
|
|
expect(body.hosts[0].neverSeen).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("round trip — POST then GET", () => {
|
|
test("the snapshot comes back field for field", async () => {
|
|
const sent = validSnapshot();
|
|
const { status } = await ingest("/hosts/thinkpad", { body: JSON.stringify(sent) });
|
|
expect(status).toBe(204);
|
|
|
|
const { body } = await request("/hosts", { auth: `Bearer ${TOKEN}` });
|
|
const host = body.hosts[0];
|
|
expect(host.id).toBe("thinkpad");
|
|
expect(host.hostname).toBe(sent.hostname);
|
|
expect(host.uptime).toBe(sent.uptime);
|
|
expect(host.cpu).toEqual(sent.cpu);
|
|
expect(host.memory).toEqual(sent.memory);
|
|
expect(host.disk).toEqual(sent.disk);
|
|
expect(host.online).toBe(true);
|
|
expect(host.neverSeen).toBeUndefined();
|
|
});
|
|
|
|
test("each workstation is reported under its own id", async () => {
|
|
await restartWithAllowlist("thinkpad,popos");
|
|
await ingest("/hosts/thinkpad");
|
|
await ingest("/hosts/popos", {
|
|
body: JSON.stringify({ ...validSnapshot(), hostname: "popos-desktop", uptime: 99 }),
|
|
});
|
|
|
|
const { body } = await request("/hosts", { auth: `Bearer ${TOKEN}` });
|
|
expect(body.hosts.map((h) => h.id)).toEqual(["popos", "thinkpad"]);
|
|
expect(body.hosts[0]).toMatchObject({ hostname: "popos-desktop", uptime: 99, online: true });
|
|
expect(body.hosts[1]).toMatchObject({ hostname: "thinkpad-x1", uptime: 4242, online: true });
|
|
});
|
|
|
|
test("a re-POST refreshes an entry that had gone stale", async () => {
|
|
writeSnapshotAged("thinkpad", 4200);
|
|
const before = await frozenRequest("/hosts");
|
|
expect(before.body.hosts[0].online).toBe(false);
|
|
|
|
await ingest("/hosts/thinkpad");
|
|
const after = await request("/hosts", { auth: `Bearer ${TOKEN}` });
|
|
expect(after.body.hosts[0].online).toBe(true);
|
|
expect(after.body.hosts[0].ageSeconds).toBeLessThan(5);
|
|
});
|
|
});
|