vps-health-api/__tests__/hosts.test.js
le king fu e181a9691c fix(review): close the three gaps found in stack review
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.
2026-08-16 14:24:43 -04:00

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);
});
});