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