test(hosts): grow the workstation API smoke pass into a full matrix

The suite that shipped with the implementation pinned the decisions that
were expensive to get wrong. This turns it into the exhaustive matrix the
surface deserves: POST /hosts/<id> is the only publicly writable endpoint
on the service, so a mistake there is an intrusion rather than an outage.

__tests__/hosts.test.js goes from 22 to 155 cases (suite 61 -> 194):

- auth: nine near-miss Authorization headers, the read token refused on
  the write route, the ingest token refused on all five read routes, and
  the two tokens proven independent — either one unset leaves the other
  path working
- rejection ordering: 404 (routing) beats 503 (ingest token unset) beats
  403 (outside the allowlist), and a malformed id answers 404 whatever
  token it carries, so route existence stays unenumerable
- routing: eleven more malformed ids, every one of them a 404. Widening
  HOST_ROUTE_RE to `^/hosts/(.+)$` turns sixteen of them red, which is
  what makes the narrow pattern a traversal control rather than a comment
- payload: twenty-two field mutations, eight bodies that are not a JSON
  object, an Infinity only a raw body can express, and the 4 KiB ceiling
  pinned from both sides — 4096 accepted, 4097 and 8 KiB refused
- persistence: 0600 mode, whitelist rebuild, 128-character cap, loadAvg
  sliced to three, the route id winning over an id in the body, an
  overwrite leaving no temp debris, and the 500 path when HOSTS_DIR
  cannot be created
- freshness: 899/900/901 exact against a frozen Date.now() instead of the
  wall clock, plus a custom HOSTS_STALE_SECONDS and its fallbacks
- listing: eight ways a snapshot file can be corrupt, each degrading its
  own entry while a healthy neighbour keeps its data

The 4 KiB hostname of the acceptance criteria is covered twice, because
the body ceiling fires before the sanitiser ever sees it: 4096 characters
are refused with 413, and 3500 characters (which fit) are truncated to
128 with the long value absent from the file.

index.js is untouched. No defect surfaced, and the two mutations used to
prove the net bites were reverted.

Resolves #14
This commit is contained in:
le king fu 2026-08-16 12:05:31 -04:00
parent fedb1c81dc
commit 56ee580167

View file

@ -3,13 +3,30 @@ const fs = require("node:fs");
const os = require("node:os"); const os = require("node:os");
const path = require("node:path"); const path = require("node:path");
// Smoke coverage for the workstation snapshot surface (issue #13). // Coverage matrix for the workstation snapshot surface (issues #13 and #14).
// //
// This is deliberately NOT the exhaustive matrix — that is issue #14. What is // The first blocks landed with the implementation (#13) and pinned the handful
// pinned here is the handful of decisions that would be expensive to get wrong // of decisions that would be expensive to get wrong and silent to regress: the
// and silent to regress: the routing-before-auth ordering, the narrow host id // routing-before-auth ordering, the narrow host id regex as the traversal
// regex as the traversal control, 401 winning over 403, the whitelist rebuild, // control, 401 winning over 403, the whitelist rebuild, and the server-stamped
// and the server-stamped freshness. // 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 TOKEN = "test-token";
const INGEST_TOKEN = "test-ingest-token"; const INGEST_TOKEN = "test-ingest-token";
@ -51,8 +68,8 @@ function stopServer() {
}); });
} }
async function request(route, { method = "GET", auth, body } = {}) { async function request(route, { method = "GET", auth, body, headers: extra } = {}) {
const headers = {}; const headers = { ...extra };
if (auth) headers.Authorization = auth; if (auth) headers.Authorization = auth;
if (body !== undefined) headers["Content-Type"] = "application/json"; if (body !== undefined) headers["Content-Type"] = "application/json";
const res = await fetch(`${baseUrl}${route}`, { method, headers, body }); const res = await fetch(`${baseUrl}${route}`, { method, headers, body });
@ -60,8 +77,96 @@ async function request(route, { method = "GET", auth, body } = {}) {
return { status: res.status, body: parsed }; return { status: res.status, body: parsed };
} }
function ingest(route, { auth = `Bearer ${INGEST_TOKEN}`, body = JSON.stringify(validSnapshot()) } = {}) { function ingest(route, { auth = `Bearer ${INGEST_TOKEN}`, body = JSON.stringify(validSnapshot()), headers } = {}) {
return request(route, { method: "POST", auth, body }); 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 {
return 413;
}
} }
beforeEach(async () => { beforeEach(async () => {
@ -76,6 +181,10 @@ beforeEach(async () => {
process.env.REPORTS_DIR = path.join(tmpDir, "reports"); process.env.REPORTS_DIR = path.join(tmpDir, "reports");
process.env.DEFENSEURS_AGENTS_MAP_PATH = path.join(tmpDir, "agents-map.json"); process.env.DEFENSEURS_AGENTS_MAP_PATH = path.join(tmpDir, "agents-map.json");
process.env.DEFENSEURS_STATUS_PATH = path.join(tmpDir, "status.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(); await startServer();
}); });
@ -98,6 +207,20 @@ describe("POST /hosts/<id> — routing is the traversal control", () => {
["uppercase id", "/hosts/THINKPAD"], ["uppercase id", "/hosts/THINKPAD"],
["id with a slash", "/hosts/thinkpad/extra"], ["id with a slash", "/hosts/thinkpad/extra"],
["empty id", "/hosts/"], ["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) => { test.each(REJECTED)("404 on POST %s", async (_label, route) => {
@ -274,4 +397,677 @@ describe("HOSTS_ALLOWED_IDS validation", () => {
const { body } = await request("/hosts", { auth: `Bearer ${TOKEN}` }); const { body } = await request("/hosts", { auth: `Bearer ${TOKEN}` });
expect(body.hosts.map((h) => h.id)).toEqual(["popos", "thinkpad"]); 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);
});
}); });