test(hosts): exhaustive matrix for the workstation API (auth, allowlist, payload, freshness) #21
1 changed files with 806 additions and 10 deletions
|
|
@ -3,13 +3,30 @@ const fs = require("node:fs");
|
|||
const os = require("node:os");
|
||||
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
|
||||
// pinned here is 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.
|
||||
// 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";
|
||||
|
|
@ -51,8 +68,8 @@ function stopServer() {
|
|||
});
|
||||
}
|
||||
|
||||
async function request(route, { method = "GET", auth, body } = {}) {
|
||||
const headers = {};
|
||||
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 });
|
||||
|
|
@ -60,8 +77,96 @@ async function request(route, { method = "GET", auth, body } = {}) {
|
|||
return { status: res.status, body: parsed };
|
||||
}
|
||||
|
||||
function ingest(route, { auth = `Bearer ${INGEST_TOKEN}`, body = JSON.stringify(validSnapshot()) } = {}) {
|
||||
return request(route, { method: "POST", auth, body });
|
||||
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 {
|
||||
return 413;
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
@ -76,6 +181,10 @@ beforeEach(async () => {
|
|||
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();
|
||||
});
|
||||
|
|
@ -98,6 +207,20 @@ describe("POST /hosts/<id> — routing is the traversal control", () => {
|
|||
["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) => {
|
||||
|
|
@ -274,4 +397,677 @@ describe("HOSTS_ALLOWED_IDS validation", () => {
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue