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
597 lines
20 KiB
JavaScript
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");
|
|
});
|
|
});
|