vps-health-api/__tests__/agent.test.js
le king fu e24f5b6f00 feat(agent): push workstation metrics to /hosts/<id> from cron
Zero-dependency local agent: collect one snapshot through the shared
metrics.js, POST it once to /hosts/<id>, exit. Cron runs it every five
minutes. Nothing is queued and nothing is replayed — a heartbeat from five
minutes ago describes a machine that no longer exists, so a failed push is
logged and dropped rather than spooled.

run-push.sh sources ~/.config/maximus-host-agent.env (mode 600), refuses to
start when HOSTS_API_URL, HOSTS_INGEST_TOKEN or HOST_ID is missing, and never
enables shell tracing: the script is meant to be piped into `logger`, where
`set -x` would echo the ingest token into /var/log/syslog and journald for
good. Same reason the failure path reports only err.code and the HTTP status —
never an error object, request options, headers, or a response body. The cost
is accepted: a 400 says the payload was rejected, not why.

50 tests, including two that spawn the real process against a failing server
and scan its actual stdout and stderr for any five-character fragment of the
token. The payload is pinned against the server's own sanitizeSnapshot(), and
one case pushes a real collectMetrics() snapshot through the real ingestion
handler, so a drift between agent and server turns a test red instead of
producing a 400 at 3 a.m. on the ThinkPad.

agent/ stays out of the Docker image: the COPY line is untouched, and a test
pins that it copies files one by one and never names the directory.

Installing on a workstation — frozen copy, env file, crontab line, dry run,
and why two machines must never share a HOST_ID — is documented in
agent/README.md. The install itself belongs to the commissioning issue.

Resolves #15
2026-08-16 12:21:59 -04:00

597 lines
20 KiB
JavaScript

const http = require("node:http");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const execFileAsync = promisify(execFile);
const {
buildSnapshot,
buildTargetUrl,
postSnapshot,
readConfig,
DEFAULT_TIMEOUT_MS,
EXIT_CONFIG,
EXIT_PUSH,
} = require("../agent/push-metrics.js");
const { collectMetrics } = require("../metrics.js");
const { sanitizeSnapshot } = require("../index.js");
// Coverage for the local workstation agent (issue #15).
//
// Three things are worth stating up front, because they explain why this file
// spends most of its length on failure paths rather than on the happy one.
//
// * THE TOKEN MUST NOT SURVIVE INTO A LOG LINE. run-push.sh pipes the agent's
// output into `logger -t host-agent`; anything printed lands in syslog and
// journald for good. Every failure path here is therefore checked against
// every 5-character-or-longer fragment of the token — message, stack, JSON
// rendering — and the two spawned-process cases check the real stdout and
// stderr of a real run, which is what `logger` would actually swallow.
// * NO QUEUE, NO REPLAY. A failed push is dropped, not retried: the
// assertions count the requests the server saw and expect exactly one.
// * The payload contract is pinned against the server's own
// sanitizeSnapshot(), and one case pushes a REAL collectMetrics() snapshot
// through the REAL ingestion handler. A drift between agent and server
// shows up as a red test rather than as a 400 at 3 a.m. on the ThinkPad.
const AGENT_PATH = path.join(__dirname, "..", "agent", "push-metrics.js");
const RUNNER_PATH = path.join(__dirname, "..", "agent", "run-push.sh");
// Mixed letters and digits, no dictionary word and no run of five digits, so a
// port number or a timestamp in the output can never look like a leak.
const TOKEN = "7f2a9c4e1b6d8035a5e0c3d1";
const HOST_ID = "thinkpad";
const servers = [];
let tmpDir;
function fakeMetrics() {
return {
cpu: {
model: "Intel(R) Core(TM) i7-8650U CPU @ 1.90GHz",
cores: 8,
loadAvg: [0.42, 0.31, 0.25],
usagePercent: 12,
},
memory: { totalGB: 32, usedGB: 12.5, freeGB: 19.5, usagePercent: 39 },
disk: { totalGB: 500, usedGB: 220, freeGB: 280, usagePercent: 44 },
};
}
async function startServer(onRequest) {
const server = http.createServer(onRequest);
servers.push(server);
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
return { server, url: `http://127.0.0.1:${server.address().port}` };
}
// A recording server that answers with the given status. Returns the log of
// what it saw, so "exactly one request" and "the Authorization header was sent"
// are assertions rather than assumptions.
async function startRecordingServer(status = 204) {
const seen = [];
const { url, server } = await startServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
seen.push({
method: req.method,
url: req.url,
headers: req.headers,
body: Buffer.concat(chunks).toString("utf-8"),
});
res.writeHead(status);
res.end();
});
});
return { url, server, seen };
}
async function closeServers() {
while (servers.length) {
const server = servers.pop();
server.closeAllConnections?.();
await new Promise((resolve) => server.close(resolve));
}
}
// Every substring of the token from `min` characters up. A failure message that
// contains none of them cannot contain the token, nor a truncated half of it.
function tokenFragments(token, min = 5) {
const out = [];
for (let len = min; len <= token.length; len++) {
for (let i = 0; i + len <= token.length; i++) out.push(token.slice(i, i + len));
}
return out;
}
function expectNoTokenLeak(text, token = TOKEN) {
const haystack = String(text);
const leaked = tokenFragments(token).filter((frag) => haystack.includes(frag));
// Report the longest match only: every shorter fragment of it also matches,
// and dumping the 200-odd of them buries the failure it is reporting.
const worst = leaked.reduce((a, b) => (b.length > a.length ? b : a), "");
expect(
leaked.length,
`token leaked into the output — longest fragment found: "${worst}"`,
).toBe(0);
}
// Everything a careless `console.error(err)` would print.
function renderError(err) {
return [
err.message,
String(err),
err.stack,
JSON.stringify(err),
JSON.stringify(err, Object.getOwnPropertyNames(err)),
].join("\n");
}
async function pushTo(url, overrides = {}) {
return postSnapshot({
apiUrl: url,
token: TOKEN,
hostId: HOST_ID,
snapshot: buildSnapshot(fakeMetrics()),
timeoutMs: 2000,
...overrides,
});
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-test-"));
});
afterEach(async () => {
await closeServers();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe("payload — matches the POST /hosts/<id> contract", () => {
test("the built snapshot passes the server's sanitizeSnapshot() unchanged", () => {
const snapshot = buildSnapshot(fakeMetrics());
const result = sanitizeSnapshot(snapshot);
expect(result.error).toBeUndefined();
expect(result.snapshot).toEqual(snapshot);
});
test("exactly the documented keys, nothing more", () => {
const snapshot = buildSnapshot(fakeMetrics());
expect(Object.keys(snapshot).sort()).toEqual([
"cpu",
"disk",
"hostname",
"memory",
"uptime",
]);
expect(Object.keys(snapshot.cpu).sort()).toEqual([
"cores",
"loadAvg",
"model",
"usagePercent",
]);
for (const key of ["memory", "disk"]) {
expect(Object.keys(snapshot[key]).sort()).toEqual([
"freeGB",
"totalGB",
"usagePercent",
"usedGB",
]);
}
});
test("an extra field added to collectMetrics() does not leave the workstation", () => {
const metrics = fakeMetrics();
metrics.temperatureC = 61;
metrics.cpu.serial = "PF0X1234";
const snapshot = buildSnapshot(metrics);
expect(snapshot.temperatureC).toBeUndefined();
expect(snapshot.cpu.serial).toBeUndefined();
});
test("hostname and uptime come from the host, uptime floored like getHealth()", () => {
const snapshot = buildSnapshot(fakeMetrics());
expect(snapshot.hostname).toBe(os.hostname());
expect(Number.isInteger(snapshot.uptime)).toBe(true);
expect(snapshot.uptime).toBeGreaterThan(0);
});
test("a real collectMetrics() snapshot is accepted by sanitizeSnapshot()", async () => {
const snapshot = buildSnapshot(await collectMetrics());
const result = sanitizeSnapshot(snapshot);
expect(result.error).toBeUndefined();
expect(result.snapshot).toEqual(snapshot);
});
});
describe("delivery — one attempt, no queue", () => {
test("POSTs to /hosts/<id> with the bearer token and resolves on 204", async () => {
const { url, seen } = await startRecordingServer(204);
// Build ONCE and compare against that object. Two calls to buildSnapshot()
// straddling a second boundary differ by 1 on `uptime`, which turns this
// assertion into a one-in-several-runs failure.
const snapshot = buildSnapshot(fakeMetrics());
const result = await pushTo(url, { snapshot });
expect(result.statusCode).toBe(204);
expect(seen).toHaveLength(1);
expect(seen[0].method).toBe("POST");
expect(seen[0].url).toBe(`/hosts/${HOST_ID}`);
expect(seen[0].headers.authorization).toBe(`Bearer ${TOKEN}`);
expect(seen[0].headers["content-type"]).toBe("application/json");
expect(JSON.parse(seen[0].body)).toEqual(snapshot);
});
test("any 2xx counts as delivered", async () => {
const { url } = await startRecordingServer(200);
await expect(pushTo(url)).resolves.toEqual({ statusCode: 200 });
});
test.each([400, 401, 403, 413, 500, 503])("rejects on HTTP %i", async (status) => {
const { url } = await startRecordingServer(status);
await expect(pushTo(url)).rejects.toThrow(`push failed: HTTP ${status}`);
});
test("a rejected push is dropped, never retried", async () => {
const { url, seen } = await startRecordingServer(500);
await expect(pushTo(url)).rejects.toThrow();
await new Promise((resolve) => setTimeout(resolve, 150));
expect(seen).toHaveLength(1);
});
test("reports only err.code when the connection is refused", async () => {
const { url, server } = await startRecordingServer();
await new Promise((resolve) => server.close(resolve));
servers.pop();
await expect(pushTo(url)).rejects.toThrow("push failed: request error (code=ECONNREFUSED)");
});
test("gives up on a server that never answers, as ETIMEDOUT", async () => {
const { url } = await startServer(() => {
/* never responds */
});
const started = Date.now();
await expect(pushTo(url, { timeoutMs: 200 })).rejects.toThrow(
"push failed: request error (code=ETIMEDOUT)",
);
expect(Date.now() - started).toBeLessThan(2000);
});
test("the default timeout is 10s", () => {
expect(DEFAULT_TIMEOUT_MS).toBe(10000);
});
test("a trailing slash on HOSTS_API_URL does not double up", () => {
expect(buildTargetUrl("https://health.example.com/", "thinkpad").href).toBe(
"https://health.example.com/hosts/thinkpad",
);
expect(buildTargetUrl("https://health.example.com", "thinkpad").href).toBe(
"https://health.example.com/hosts/thinkpad",
);
});
test.each([
["ftp://health.example.com", "HOSTS_API_URL must be http:// or https://"],
["not a url", "HOSTS_API_URL is not a valid URL"],
])("refuses %s before opening a socket", async (apiUrl, message) => {
await expect(pushTo(apiUrl)).rejects.toThrow(message);
});
});
describe("the failure path never carries the token", () => {
test("HTTP failure: message, stack and JSON rendering are all clean", async () => {
const { url } = await startRecordingServer(500);
const err = await pushTo(url).catch((e) => e);
expect(err.message).toBe("push failed: HTTP 500");
expectNoTokenLeak(renderError(err));
});
test("transport failure: message, stack and JSON rendering are all clean", async () => {
const { url, server } = await startRecordingServer();
await new Promise((resolve) => server.close(resolve));
servers.pop();
const err = await pushTo(url).catch((e) => e);
expectNoTokenLeak(renderError(err));
});
test("timeout failure: message, stack and JSON rendering are all clean", async () => {
const { url } = await startServer(() => {});
const err = await pushTo(url, { timeoutMs: 200 }).catch((e) => e);
expectNoTokenLeak(renderError(err));
});
test("a token with a trailing newline fails without quoting itself", async () => {
const { url } = await startRecordingServer(204);
const err = await pushTo(url, { token: `${TOKEN}\n` }).catch((e) => e);
expect(err.message).toContain("HOSTS_INGEST_TOKEN contains an invalid character");
expectNoTokenLeak(renderError(err));
});
test("the real process output of a failing run contains no fragment of the token", async () => {
const { url } = await startRecordingServer(500);
const result = await execFileAsync(process.execPath, [AGENT_PATH], {
env: {
PATH: process.env.PATH,
HOSTS_API_URL: url,
HOSTS_INGEST_TOKEN: TOKEN,
HOST_ID,
},
}).catch((e) => e);
expect(result.code).toBe(EXIT_PUSH);
expect(result.stderr).toContain("host-agent: push failed: HTTP 500");
expectNoTokenLeak(`${result.stdout}${result.stderr}`);
});
test("the real process output of an unreachable server contains no fragment of the token", async () => {
const { url, server } = await startRecordingServer();
await new Promise((resolve) => server.close(resolve));
servers.pop();
const result = await execFileAsync(process.execPath, [AGENT_PATH], {
env: {
PATH: process.env.PATH,
HOSTS_API_URL: url,
HOSTS_INGEST_TOKEN: TOKEN,
HOST_ID,
},
}).catch((e) => e);
expect(result.code).toBe(EXIT_PUSH);
expect(result.stderr).toContain("code=ECONNREFUSED");
expectNoTokenLeak(`${result.stdout}${result.stderr}`);
});
});
describe("configuration", () => {
test.each([
["HOSTS_API_URL", "HOSTS_API_URL is not set"],
["HOSTS_INGEST_TOKEN", "HOSTS_INGEST_TOKEN is not set"],
["HOST_ID", "HOST_ID is not set"],
])("a missing %s is named, not guessed", (missing, message) => {
const env = { HOSTS_API_URL: "https://x.test", HOSTS_INGEST_TOKEN: TOKEN, HOST_ID };
delete env[missing];
const { error, config } = readConfig(env);
expect(config).toBeUndefined();
expect(error.message).toContain(message);
expectNoTokenLeak(renderError(error));
});
test.each(["THINKPAD", "-thinkpad", "think pad", "a".repeat(33), "../thinkpad"])(
"rejects the malformed HOST_ID %j client-side",
(hostId) => {
const { error } = readConfig({
HOSTS_API_URL: "https://x.test",
HOSTS_INGEST_TOKEN: TOKEN,
HOST_ID: hostId,
});
expect(error.message).toContain("HOST_ID must match");
},
);
test.each([
["ftp://health.example.com", "HOSTS_API_URL must be http:// or https://"],
["not a url", "HOSTS_API_URL is not a valid URL"],
])("a broken HOSTS_API_URL (%j) is an install error, not a push error", (apiUrl, message) => {
const { error, config } = readConfig({
HOSTS_API_URL: apiUrl,
HOSTS_INGEST_TOKEN: TOKEN,
HOST_ID,
});
expect(config).toBeUndefined();
expect(error.message).toContain(message);
});
test("accepts a well-formed config and defaults the timeout", () => {
const { config } = readConfig({
HOSTS_API_URL: "https://health.example.com",
HOSTS_INGEST_TOKEN: TOKEN,
HOST_ID,
});
expect(config).toEqual({
apiUrl: "https://health.example.com",
token: TOKEN,
hostId: HOST_ID,
timeoutMs: DEFAULT_TIMEOUT_MS,
});
});
test("--dry-run prints the payload and touches no network", async () => {
const { stdout } = await execFileAsync(process.execPath, [AGENT_PATH, "--dry-run"], {
env: { PATH: process.env.PATH },
});
const snapshot = JSON.parse(stdout);
expect(sanitizeSnapshot(snapshot).error).toBeUndefined();
expect(snapshot.hostname).toBe(os.hostname());
});
});
describe("run-push.sh", () => {
function writeEnvFile(lines, mode = 0o600) {
const file = path.join(tmpDir, "maximus-host-agent.env");
fs.writeFileSync(file, `${lines.join("\n")}\n`, { mode });
return file;
}
async function runWrapper(envFile) {
return execFileAsync(RUNNER_PATH, [], {
env: { PATH: process.env.PATH, HOME: tmpDir, HOST_AGENT_ENV_FILE: envFile },
}).catch((e) => e);
}
test("delivers the snapshot end to end", async () => {
const { url, seen } = await startRecordingServer(204);
const envFile = writeEnvFile([
`HOSTS_API_URL=${url}`,
`HOSTS_INGEST_TOKEN=${TOKEN}`,
`HOST_ID=${HOST_ID}`,
`NODE_BIN=${process.execPath}`,
]);
const result = await runWrapper(envFile);
expect(result.stderr).toBe("");
expect(result.stdout).toContain(`host-agent: ok id=${HOST_ID} status=204`);
expect(seen).toHaveLength(1);
expect(seen[0].headers.authorization).toBe(`Bearer ${TOKEN}`);
expect(sanitizeSnapshot(JSON.parse(seen[0].body)).error).toBeUndefined();
expectNoTokenLeak(`${result.stdout}${result.stderr}`);
});
test.each([
[["HOSTS_INGEST_TOKEN", "HOST_ID"], "HOSTS_API_URL is not set"],
[["HOSTS_API_URL", "HOST_ID"], "HOSTS_INGEST_TOKEN is not set"],
[["HOSTS_API_URL", "HOSTS_INGEST_TOKEN"], "HOST_ID is not set"],
])("stops when a variable is missing (%j)", async (present, message) => {
const values = {
HOSTS_API_URL: "https://health.example.com",
HOSTS_INGEST_TOKEN: TOKEN,
HOST_ID,
};
const envFile = writeEnvFile(present.map((key) => `${key}=${values[key]}`));
const result = await runWrapper(envFile);
expect(result.code).toBe(1);
expect(result.stderr).toContain(message);
expectNoTokenLeak(`${result.stdout}${result.stderr}`);
});
test("stops when the env file is absent", async () => {
const missing = path.join(tmpDir, "nope.env");
const result = await runWrapper(missing);
expect(result.code).toBe(1);
expect(result.stderr).toContain(`host-agent: env file not found: ${missing}`);
});
test("warns about a world-readable env file but still runs", async () => {
const { url, seen } = await startRecordingServer(204);
const envFile = writeEnvFile(
[
`HOSTS_API_URL=${url}`,
`HOSTS_INGEST_TOKEN=${TOKEN}`,
`HOST_ID=${HOST_ID}`,
`NODE_BIN=${process.execPath}`,
],
0o644,
);
const result = await runWrapper(envFile);
expect(result.stderr).toContain("is mode 644, expected 600");
expect(seen).toHaveLength(1);
expectNoTokenLeak(`${result.stdout}${result.stderr}`);
});
test("never enables shell tracing — `set -x` would echo the token into syslog", () => {
const script = fs.readFileSync(RUNNER_PATH, "utf-8");
const traced = script
.split("\n")
.filter((line) => !line.trim().startsWith("#"))
.filter((line) => /\bset\b[^#\n]*-[a-z]*x/.test(line));
expect(traced).toEqual([]);
});
test("is executable", () => {
expect(fs.statSync(RUNNER_PATH).mode & 0o111).toBeGreaterThan(0);
});
});
describe("the agent stays out of the server image", () => {
test("the Dockerfile copies files one by one and none of them is the agent", () => {
const dockerfile = fs.readFileSync(path.join(__dirname, "..", "Dockerfile"), "utf-8");
const copies = dockerfile
.split("\n")
.filter((line) => /^\s*(COPY|ADD)\b/i.test(line));
expect(copies).toHaveLength(1);
expect(copies[0]).not.toMatch(/agent/);
// A bare `COPY . .` would drag agent/ in without ever naming it.
expect(copies[0]).not.toMatch(/^\s*COPY\s+\.\s/i);
});
});
describe("against the real ingestion handler", () => {
const savedEnv = {};
const ENV_KEYS = ["HOSTS_INGEST_TOKEN", "HOSTS_DIR", "HOSTS_ALLOWED_IDS", "HEALTH_TOKEN"];
beforeEach(() => {
for (const key of ENV_KEYS) savedEnv[key] = process.env[key];
process.env.HOSTS_INGEST_TOKEN = TOKEN;
process.env.HOSTS_DIR = path.join(tmpDir, "hosts");
process.env.HOSTS_ALLOWED_IDS = HOST_ID;
process.env.HEALTH_TOKEN = "read-token";
});
afterEach(() => {
for (const key of ENV_KEYS) {
if (savedEnv[key] === undefined) delete process.env[key];
else process.env[key] = savedEnv[key];
}
});
test("a real snapshot is accepted with 204 and persisted field for field", async () => {
// Fresh import so index.js picks up the env above.
delete require.cache[require.resolve("../index.js")];
const { handler } = require("../index.js");
const { url } = await startServer(handler);
const snapshot = buildSnapshot(await collectMetrics());
const result = await postSnapshot({
apiUrl: url,
token: TOKEN,
hostId: HOST_ID,
snapshot,
timeoutMs: 5000,
});
expect(result.statusCode).toBe(204);
const written = JSON.parse(
fs.readFileSync(path.join(process.env.HOSTS_DIR, `${HOST_ID}.json`), "utf-8"),
);
expect(written.id).toBe(HOST_ID);
expect(typeof written.receivedAt).toBe("string");
delete written.id;
delete written.receivedAt;
expect(written).toEqual(snapshot);
});
test("a wrong ingest token is rejected with 401 and no fragment leaks", async () => {
delete require.cache[require.resolve("../index.js")];
const { handler } = require("../index.js");
const { url } = await startServer(handler);
const err = await postSnapshot({
apiUrl: url,
token: "wrong-token",
hostId: HOST_ID,
snapshot: buildSnapshot(fakeMetrics()),
timeoutMs: 5000,
}).catch((e) => e);
expect(err.message).toBe("push failed: HTTP 401");
expectNoTokenLeak(renderError(err));
});
test("a host id outside the allowlist surfaces as HTTP 403", async () => {
process.env.HOSTS_ALLOWED_IDS = "someone-else";
delete require.cache[require.resolve("../index.js")];
const { handler } = require("../index.js");
const { url } = await startServer(handler);
await expect(
postSnapshot({
apiUrl: url,
token: TOKEN,
hostId: HOST_ID,
snapshot: buildSnapshot(fakeMetrics()),
timeoutMs: 5000,
}),
).rejects.toThrow("push failed: HTTP 403");
});
});