const http = require("node:http"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); // Claude usage snapshot (issue #48): POST /claude-usage pushed by the ThinkPad // agent, GET /claude-usage read by the admin dashboard. // // Three things are pinned here. // * The sanitizer, as a pure function (claude-usage.js): the whitelist, the // contract bounds, and the rule that an invalid value is dropped rather // than coerced. // * The gate: a third token kind, `claude-ingest`, isolated both ways from // the workstation token and from the read token, each ingestion token // failing closed to a 503 that names its own variable. // * The round trip: ceiling, atomic write, server-stamped receivedAt, and a // staleness decided by the server alone. const TOKEN = "test-token"; const INGEST_TOKEN = "test-ingest-token"; const CLAUDE_TOKEN = "test-claude-token"; // Restated rather than imported, so a silent change to the constant turns the // matrix red instead of quietly re-drawing the ceiling (same rule as hosts). const CLAUDE_MAX_BODY_BYTES = 65536; const HOSTS_MAX_BODY_BYTES = 4096; const DEFAULT_STALE_SECONDS = 2700; const FROZEN_NOW = Date.UTC(2026, 8, 27, 19, 0, 0); let tmpDir; let usagePath; let server; let baseUrl; function validUsage() { return { schemaVersion: 1, generatedAt: "2026-09-27T19:00:00.000Z", claudeCodeVersion: "2.1.283", timeZone: "America/Toronto", limits: { status: "ok", detail: "http_429", fetchedAt: "2026-09-27T19:00:00.000Z", items: [ { kind: "session", group: "session", percent: 1, severity: "normal", resetsAt: "2026-09-27T21:30:00Z", model: null }, { kind: "weekly_scoped", group: "weekly", percent: 63, severity: "normal", resetsAt: "2026-10-01T07:00:00Z", model: "Fable" }, ], }, days: [ { date: "2026-09-26", models: {} }, { date: "2026-09-27", models: { "claude-opus-5-5": { input: 2, output: 1950, cacheCreation: 43176, cacheRead: 33814, requests: 1 } }, }, ], projects: [ { name: "simpl-resultat", work: 123456, cacheRead: 7890123, requests: 812 }, { name: "(autres)", work: 10, cacheRead: 20, requests: 3 }, ], }; } function freshSanitizer() { delete require.cache[require.resolve("../claude-usage.js")]; return require("../claude-usage.js"); } function startServer() { delete require.cache[require.resolve("../index.js")]; delete require.cache[require.resolve("../claude-usage.js")]; const { handler } = require("../index.js"); server = http.createServer(handler); return new Promise((resolve) => { server.listen(0, () => { baseUrl = `http://127.0.0.1:${server.address().port}`; resolve(); }); }); } function stopServer() { return new Promise((resolve) => { if (!server) return resolve(); server.close(() => resolve()); }); } async function restartServer() { await stopServer(); await startServer(); } 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 }); const parsed = await res.json().catch(() => ({})); return { status: res.status, body: parsed }; } function push({ auth = `Bearer ${CLAUDE_TOKEN}`, body = JSON.stringify(validUsage()) } = {}) { return request("/claude-usage", { method: "POST", auth, body }); } function read(auth = `Bearer ${TOKEN}`) { return request("/claude-usage", { auth }); } // Same contract as hosts.test.js: the 413 must come back as a status, a dead // socket is reported as such and never folded into the expected value. async function pushOversized(route, body, auth) { try { const { status } = await request(route, { method: "POST", auth, body }); return status; } catch (err) { return `transport error instead of a status: ${err.cause?.code || err.code || err.message}`; } } // A JSON body of exactly `bytes` bytes around a payload that is otherwise valid. function bodyOfExactly(base, bytes) { const padded = { ...base, padding: "" }; const overhead = JSON.stringify(padded).length; return JSON.stringify({ ...padded, padding: "x".repeat(bytes - overhead) }); } function writeStored(record) { fs.mkdirSync(path.dirname(usagePath), { recursive: true }); fs.writeFileSync(usagePath, typeof record === "string" ? record : JSON.stringify(record)); } async function frozenRead() { const realNow = Date.now; Date.now = () => FROZEN_NOW; try { return await read(); } finally { Date.now = realNow; } } beforeEach(async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-claude-usage-test-")); usagePath = path.join(tmpDir, "claude-usage", "claude-usage.json"); process.env.HEALTH_TOKEN = TOKEN; process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN; process.env.CLAUDE_USAGE_INGEST_TOKEN = CLAUDE_TOKEN; process.env.CLAUDE_USAGE_PATH = usagePath; delete process.env.CLAUDE_USAGE_STALE_SECONDS; process.env.HOSTS_DIR = path.join(tmpDir, "hosts"); process.env.HOSTS_ALLOWED_IDS = "thinkpad"; 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"); process.env.DEFENSEURS_FINDINGS_STATE_PATH = path.join(tmpDir, "findings-state.json"); // Closed port: no request may reach the real IdP. process.env.LOGTO_HEALTH_URL = "http://127.0.0.1:1/oidc/.well-known/openid-configuration"; await startServer(); }); afterEach(async () => { await stopServer(); fs.rmSync(tmpDir, { recursive: true, force: true }); }); afterAll(() => { // Other files reuse the worker: leave no claude-usage config behind. delete process.env.CLAUDE_USAGE_INGEST_TOKEN; delete process.env.CLAUDE_USAGE_PATH; delete process.env.CLAUDE_USAGE_STALE_SECONDS; }); // --- The sanitizer, as a pure function -------------------------------------- describe("sanitizeClaudeUsage — the contract, kept as is", () => { test("a valid snapshot comes back identical", () => { const { sanitizeClaudeUsage } = freshSanitizer(); expect(sanitizeClaudeUsage(validUsage())).toEqual({ snapshot: validUsage() }); }); test("it is idempotent, so GET can re-run it on the stored file", () => { const { sanitizeClaudeUsage } = freshSanitizer(); const noisy = { ...validUsage(), extra: 1, timeZone: "T".repeat(500) }; const once = sanitizeClaudeUsage(noisy).snapshot; expect(sanitizeClaudeUsage(once).snapshot).toEqual(once); }); test("the nullable fields keep an explicit null", () => { const { sanitizeClaudeUsage } = freshSanitizer(); const payload = validUsage(); payload.limits.fetchedAt = null; payload.limits.items[0].resetsAt = null; const { snapshot } = sanitizeClaudeUsage(payload); expect(snapshot.limits.fetchedAt).toBeNull(); expect(snapshot.limits.items[0].resetsAt).toBeNull(); expect(snapshot.limits.items[0].model).toBeNull(); }); test("every status of the enum is kept", () => { const { sanitizeClaudeUsage, LIMIT_STATUSES } = freshSanitizer(); expect(LIMIT_STATUSES).toEqual(["ok", "token_expired", "http_error", "unavailable", "disabled"]); for (const status of LIMIT_STATUSES) { const payload = validUsage(); payload.limits.status = status; expect(sanitizeClaudeUsage(payload).snapshot.limits.status).toBe(status); } }); test("the documented detail codes are kept", () => { const { sanitizeClaudeUsage } = freshSanitizer(); for (const detail of ["no_credentials", "parse_error", "no_version", "timeout", "network", "bad_response", "http_503"]) { const payload = validUsage(); payload.limits.detail = detail; expect(sanitizeClaudeUsage(payload).snapshot.limits.detail).toBe(detail); } }); test("0 is a legitimate count and 0 and 100 legitimate percents", () => { const { sanitizeClaudeUsage } = freshSanitizer(); const payload = validUsage(); payload.limits.items[0].percent = 0; payload.limits.items[1].percent = 100; payload.projects[0].work = 0; const { snapshot } = sanitizeClaudeUsage(payload); expect(snapshot.limits.items.map((item) => item.percent)).toEqual([0, 100]); expect(snapshot.projects[0].work).toBe(0); }); }); describe("sanitizeClaudeUsage — the whole body refused", () => { test.each([ ["an array", []], ["null", null], ["a string", "snapshot"], ["a number", 1], ])("error on %s", (_label, payload) => { const { sanitizeClaudeUsage } = freshSanitizer(); expect(sanitizeClaudeUsage(payload)).toEqual({ error: "body must be a JSON object" }); }); test.each([ ["missing", undefined], ["2", 2], ["the string \"1\"", "1"], ])("error when schemaVersion is %s", (_label, schemaVersion) => { const { sanitizeClaudeUsage } = freshSanitizer(); expect(sanitizeClaudeUsage({ ...validUsage(), schemaVersion })).toEqual({ error: "schemaVersion must be 1" }); }); }); describe("sanitizeClaudeUsage — invalid values are dropped, not coerced", () => { test("unknown fields are dropped at every level", () => { const { sanitizeClaudeUsage } = freshSanitizer(); const payload = validUsage(); payload.extra = "x"; payload.limits.extra = "x"; payload.limits.items[0].extra = "x"; payload.days[1].extra = "x"; payload.days[1].models["claude-opus-5-5"].extra = 1; payload.projects[0].extra = "x"; expect(sanitizeClaudeUsage(payload).snapshot).toEqual(validUsage()); }); test("a __proto__ key, top-level or as a model name, never survives", () => { const { sanitizeClaudeUsage } = freshSanitizer(); const raw = JSON.stringify(validUsage()) .replace('"schemaVersion":1', '"__proto__":{"polluted":true},"schemaVersion":1') .replace('"models":{}', '"models":{"__proto__":{"input":1}}'); const { snapshot } = sanitizeClaudeUsage(JSON.parse(raw)); expect(Object.keys(snapshot)).not.toContain("__proto__"); expect(Object.keys(snapshot.days[0].models)).toEqual([]); expect({}.polluted).toBeUndefined(); }); const BAD_NUMBERS = [ ["negative", -1], ["past 2^53", 2 ** 53 + 2], ["exactly 2^53 (not a safe integer)", 2 ** 53], ["fractional", 1.5], ["a string", "12"], ["null", null], ]; test.each(BAD_NUMBERS)("a %s model counter is dropped", (_label, value) => { const { sanitizeClaudeUsage } = freshSanitizer(); const payload = validUsage(); payload.days[1].models["claude-opus-5-5"].output = value; const model = sanitizeClaudeUsage(payload).snapshot.days[1].models["claude-opus-5-5"]; expect(model).not.toHaveProperty("output"); expect(model.input).toBe(2); }); test.each(BAD_NUMBERS)("a %s project counter is dropped", (_label, value) => { const { sanitizeClaudeUsage } = freshSanitizer(); const payload = validUsage(); payload.projects[0].work = value; const project = sanitizeClaudeUsage(payload).snapshot.projects[0]; expect(project).not.toHaveProperty("work"); expect(project.requests).toBe(812); }); test("a number past 2^53 written in the raw JSON is dropped", () => { const { sanitizeClaudeUsage } = freshSanitizer(); const raw = JSON.stringify(validUsage()).replace('"requests":812', '"requests":9007199254740993'); expect(sanitizeClaudeUsage(JSON.parse(raw)).snapshot.projects[0]).not.toHaveProperty("requests"); }); test.each([ ["over 100", 101], ["negative", -1], ["fractional", 63.5], ])("a percent %s is dropped", (_label, value) => { const { sanitizeClaudeUsage } = freshSanitizer(); const payload = validUsage(); payload.limits.items[1].percent = value; const item = sanitizeClaudeUsage(payload).snapshot.limits.items[1]; expect(item).not.toHaveProperty("percent"); expect(item.kind).toBe("weekly_scoped"); }); test.each([ ["an unknown value", "rate_limited"], ["a different case", "OK"], ["a number", 1], ])("a status that is %s is dropped", (_label, status) => { const { sanitizeClaudeUsage } = freshSanitizer(); const payload = validUsage(); payload.limits.status = status; const { limits } = sanitizeClaudeUsage(payload).snapshot; expect(limits).not.toHaveProperty("status"); expect(limits.items).toHaveLength(2); }); // `detail` is a reason code, never free text: an upstream response body // must not ride along to the dashboard. test.each([ ["free text with spaces", "HTTP 500 Internal Server Error"], ["markup", ""], ["uppercase", "HTTP_429"], ["empty", ""], ["33 characters", "a".repeat(33)], ["a number", 429], ])("a detail that is %s is dropped", (_label, detail) => { const { sanitizeClaudeUsage } = freshSanitizer(); const payload = validUsage(); payload.limits.detail = detail; expect(sanitizeClaudeUsage(payload).snapshot.limits).not.toHaveProperty("detail"); }); test("a detail of exactly 32 characters is kept", () => { const { sanitizeClaudeUsage } = freshSanitizer(); const payload = validUsage(); payload.limits.detail = "a".repeat(32); expect(sanitizeClaudeUsage(payload).snapshot.limits.detail).toBe("a".repeat(32)); }); test.each([ ["a date without time", "2026-09-27"], ["no offset", "2026-09-27T19:00:00"], ["an impossible instant", "2026-13-45T99:00:00Z"], ["free text", "yesterday"], ["a number", 1759000000000], ])("a timestamp that is %s is dropped", (_label, value) => { const { sanitizeClaudeUsage } = freshSanitizer(); const payload = validUsage(); payload.generatedAt = value; payload.limits.fetchedAt = value; payload.limits.items[0].resetsAt = value; const { snapshot } = sanitizeClaudeUsage(payload); expect(snapshot).not.toHaveProperty("generatedAt"); expect(snapshot.limits).not.toHaveProperty("fetchedAt"); expect(snapshot.limits.items[0]).not.toHaveProperty("resetsAt"); }); test("an offset timestamp is a valid ISO date", () => { const { sanitizeClaudeUsage } = freshSanitizer(); const payload = { ...validUsage(), generatedAt: "2026-09-27T15:00:00.000-04:00" }; expect(sanitizeClaudeUsage(payload).snapshot.generatedAt).toBe("2026-09-27T15:00:00.000-04:00"); }); test.each([ ["no zero padding", "2026-9-27"], ["a day that does not exist", "2026-02-31"], ["a timestamp", "2026-09-27T00:00:00Z"], ["a number", 20260927], ])("a day whose date is %s is dropped", (_label, date) => { const { sanitizeClaudeUsage } = freshSanitizer(); const payload = validUsage(); payload.days[0].date = date; expect(sanitizeClaudeUsage(payload).snapshot.days.map((day) => day.date)).toEqual(["2026-09-27"]); }); test.each([ ["uppercase", "Claude-Opus"], ["a leading dot", ".hidden"], ["a slash", "claude/opus"], ["65 characters", `a${"b".repeat(64)}`], ])("a model key that is %s is dropped", (_label, key) => { const { sanitizeClaudeUsage } = freshSanitizer(); const payload = validUsage(); payload.days[0].models[key] = { input: 1 }; expect(sanitizeClaudeUsage(payload).snapshot.days[0].models).toEqual({}); }); test("a model key of 64 characters is kept", () => { const { sanitizeClaudeUsage } = freshSanitizer(); const payload = validUsage(); const key = `a${"b".repeat(63)}`; payload.days[0].models[key] = { input: 1 }; expect(sanitizeClaudeUsage(payload).snapshot.days[0].models).toEqual({ [key]: { input: 1 } }); }); test("strings are truncated to 128 characters", () => { const { sanitizeClaudeUsage } = freshSanitizer(); const payload = validUsage(); payload.claudeCodeVersion = "v".repeat(500); payload.timeZone = "t".repeat(500); payload.limits.items[0].kind = "k".repeat(500); payload.limits.items[1].model = "m".repeat(500); payload.projects[0].name = "n".repeat(500); const { snapshot } = sanitizeClaudeUsage(payload); expect(snapshot.claudeCodeVersion).toBe("v".repeat(128)); expect(snapshot.timeZone).toBe("t".repeat(128)); expect(snapshot.limits.items[0].kind).toBe("k".repeat(128)); expect(snapshot.limits.items[1].model).toBe("m".repeat(128)); expect(snapshot.projects[0].name).toBe("n".repeat(128)); }); test("a section of the wrong type is dropped, the rest kept", () => { const { sanitizeClaudeUsage } = freshSanitizer(); const payload = { ...validUsage(), limits: "ok", days: {}, projects: null }; const { snapshot } = sanitizeClaudeUsage(payload); expect(snapshot).not.toHaveProperty("limits"); expect(snapshot).not.toHaveProperty("days"); expect(snapshot).not.toHaveProperty("projects"); expect(snapshot.timeZone).toBe("America/Toronto"); }); test("entries that are not objects are dropped from every list", () => { const { sanitizeClaudeUsage } = freshSanitizer(); const payload = validUsage(); payload.days.unshift("2026-09-25", null); payload.limits.items.push([1]); payload.projects.push(42); payload.days[3].models["claude-opus-5-5"] = [1, 2]; const { snapshot } = sanitizeClaudeUsage(payload); expect(snapshot.days).toHaveLength(2); expect(snapshot.days[1].models).toEqual({}); expect(snapshot.limits.items).toHaveLength(2); expect(snapshot.projects).toHaveLength(2); }); }); describe("sanitizeClaudeUsage — list bounds", () => { function dayList(count) { const start = Date.UTC(2026, 7, 1); return Array.from({ length: count }, (_, i) => ({ date: new Date(start + i * 86400000).toISOString().slice(0, 10), models: {}, })); } test("31 days are kept; past that, the most recent 31", () => { const { sanitizeClaudeUsage, MAX_DAYS } = freshSanitizer(); expect(MAX_DAYS).toBe(31); expect(sanitizeClaudeUsage({ ...validUsage(), days: dayList(31) }).snapshot.days).toEqual(dayList(31)); const days = sanitizeClaudeUsage({ ...validUsage(), days: dayList(40) }).snapshot.days; expect(days).toEqual(dayList(40).slice(-31)); }); test("12 models per day; past that, the first 12", () => { const { sanitizeClaudeUsage, MAX_MODELS_PER_DAY } = freshSanitizer(); expect(MAX_MODELS_PER_DAY).toBe(12); const payload = validUsage(); payload.days[0].models = Object.fromEntries( Array.from({ length: 15 }, (_, i) => [`model-${String(i).padStart(2, "0")}`, { requests: i }]), ); const models = sanitizeClaudeUsage(payload).snapshot.days[0].models; expect(Object.keys(models)).toHaveLength(12); expect(Object.keys(models).at(-1)).toBe("model-11"); }); test("12 limit items; past that, the first 12", () => { const { sanitizeClaudeUsage, MAX_LIMIT_ITEMS } = freshSanitizer(); expect(MAX_LIMIT_ITEMS).toBe(12); const payload = validUsage(); payload.limits.items = Array.from({ length: 14 }, (_, i) => ({ kind: `k${i}`, percent: i })); const items = sanitizeClaudeUsage(payload).snapshot.limits.items; expect(items).toHaveLength(12); expect(items.at(-1).kind).toBe("k11"); }); test("9 projects (top 8 plus \"(autres)\"); past that, the first 9", () => { const { sanitizeClaudeUsage, MAX_PROJECTS } = freshSanitizer(); expect(MAX_PROJECTS).toBe(9); const payload = validUsage(); payload.projects = Array.from({ length: 11 }, (_, i) => ({ name: `p${i}`, work: i })); const projects = sanitizeClaudeUsage(payload).snapshot.projects; expect(projects).toHaveLength(9); expect(projects.at(-1).name).toBe("p8"); }); test("the body ceiling is 64 KiB", () => { expect(freshSanitizer().CLAUDE_USAGE_MAX_BODY_BYTES).toBe(CLAUDE_MAX_BODY_BYTES); }); }); // --- The gate --------------------------------------------------------------- describe("POST /claude-usage — authentication", () => { test.each([ ["no Authorization header", null], ["a wrong token", "Bearer wrong"], ["the workstation ingest token", `Bearer ${INGEST_TOKEN}`], ["the read token", `Bearer ${TOKEN}`], ["the Claude token without the Bearer scheme", CLAUDE_TOKEN], ["a prefix of the Claude token", `Bearer ${CLAUDE_TOKEN.slice(0, -1)}`], ])("401 with %s, and nothing is written", async (_label, auth) => { const { status, body } = await push({ auth }); expect(status).toBe(401); expect(body.error).toBe("Unauthorized"); expect(fs.existsSync(usagePath)).toBe(false); }); test("the Claude token does not open POST /hosts/", async () => { const { status, body } = await request("/hosts/thinkpad", { method: "POST", auth: `Bearer ${CLAUDE_TOKEN}`, body: "{}", }); expect(status).toBe(401); expect(body.error).toBe("Unauthorized"); expect(fs.existsSync(path.join(process.env.HOSTS_DIR, "thinkpad.json"))).toBe(false); }); test.each(["/claude-usage", "/hosts", "/health", "/defenseurs"])( "the Claude token does not open GET %s", async (route) => { const { status, body } = await request(route, { auth: `Bearer ${CLAUDE_TOKEN}` }); expect(status).toBe(401); expect(body.error).toBe("Unauthorized"); }, ); test("the comparison goes through timingSafeEqual on two digests", async () => { const crypto = require("node:crypto"); const spy = vi.spyOn(crypto, "timingSafeEqual"); try { await restartServer(); const { status } = await push({ auth: "Bearer x" }); expect(status).toBe(401); expect(spy).toHaveBeenCalledTimes(1); const [a, b] = spy.mock.calls[0]; expect([a.length, b.length]).toEqual([32, 32]); } finally { spy.mockRestore(); } }); }); describe("GET /claude-usage — authentication", () => { test.each([ ["no Authorization header", null], ["a wrong token", "Bearer wrong"], ["the workstation ingest token", `Bearer ${INGEST_TOKEN}`], ])("401 with %s", async (_label, auth) => { const { status, body } = await read(auth); expect(status).toBe(401); expect(body.error).toBe("Unauthorized"); }); test("401 when HEALTH_TOKEN is unset (fail-closed)", async () => { delete process.env.HEALTH_TOKEN; await restartServer(); const { status, body } = await read(); expect(status).toBe(401); expect(body.error).toBe("HEALTH_TOKEN not configured"); }); }); describe("fail-closed — each ingestion token names its own variable", () => { test("503 naming CLAUDE_USAGE_INGEST_TOKEN when it is unset", async () => { delete process.env.CLAUDE_USAGE_INGEST_TOKEN; await restartServer(); for (const auth of [null, `Bearer ${CLAUDE_TOKEN}`, `Bearer ${INGEST_TOKEN}`]) { const { status, body } = await push({ auth }); expect(status).toBe(503); expect(body.error).toBe("CLAUDE_USAGE_INGEST_TOKEN not configured"); } expect(fs.existsSync(usagePath)).toBe(false); }); test("the other routes are unaffected when CLAUDE_USAGE_INGEST_TOKEN is unset", async () => { delete process.env.CLAUDE_USAGE_INGEST_TOKEN; await restartServer(); expect((await read()).status).toBe(200); expect((await request("/hosts", { auth: `Bearer ${TOKEN}` })).status).toBe(200); }); test("/hosts keeps its 503 naming HOSTS_INGEST_TOKEN, and /claude-usage keeps working", async () => { delete process.env.HOSTS_INGEST_TOKEN; await restartServer(); const hosts = await request("/hosts/thinkpad", { method: "POST", auth: `Bearer ${CLAUDE_TOKEN}`, body: "{}" }); expect(hosts.status).toBe(503); expect(hosts.body.error).toBe("HOSTS_INGEST_TOKEN not configured"); expect((await push()).status).toBe(204); }); }); describe("routing", () => { test.each([ ["PUT", "/claude-usage"], ["DELETE", "/claude-usage"], ["POST", "/claude-usage/"], ["POST", "/claude-usage/x"], ["GET", "/claude-usage/../hosts/thinkpad.json"], ])("404 (not 401) on %s %s without Authorization", async (method, route) => { const { status, body } = await request(route, { method }); 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(); }); test("a 401 on POST /claude-usage is logged", async () => { await push({ auth: "Bearer wrong" }); const line = warn.mock.calls.at(-1)[0]; expect(line).toContain("[auth] 401 POST /claude-usage"); expect(line).toContain("id=-"); expect(line).toContain("reason=missing or invalid bearer token"); }); test("a 401 on GET /claude-usage is logged", async () => { await read(null); expect(warn.mock.calls.at(-1)[0]).toContain("[auth] 401 GET /claude-usage"); }); test("a 503 names its cause in the log", async () => { delete process.env.CLAUDE_USAGE_INGEST_TOKEN; await restartServer(); await push(); expect(warn.mock.calls.at(-1)[0]).toContain("reason=claude usage ingest token not configured"); }); }); // --- Ingestion -------------------------------------------------------------- describe("POST /claude-usage — the 64 KiB ceiling", () => { const claudeAuth = `Bearer ${CLAUDE_TOKEN}`; test("a body of exactly 65 536 bytes is accepted, the padding dropped", async () => { const body = bodyOfExactly(validUsage(), CLAUDE_MAX_BODY_BYTES); expect(Buffer.byteLength(body)).toBe(CLAUDE_MAX_BODY_BYTES); const { status } = await push({ body }); expect(status).toBe(204); expect(fs.readFileSync(usagePath, "utf-8")).not.toContain("padding"); }); test("a body of 65 537 bytes is refused with 413 and nothing is written", async () => { const body = bodyOfExactly(validUsage(), CLAUDE_MAX_BODY_BYTES + 1); expect(await pushOversized("/claude-usage", body, claudeAuth)).toBe(413); expect(fs.existsSync(usagePath)).toBe(false); }); test("POST /hosts/ keeps its own 4 KiB ceiling: 413 at 4 097 bytes", async () => { const hostBase = { hostname: "thinkpad-x1", uptime: 4242, cpu: { model: "Intel Core i7", cores: 8, loadAvg: [0.5, 0.4, 0.3], usagePercent: 12 }, memory: { totalGB: 32, usedGB: 12.5, freeGB: 19.5, usagePercent: 39 }, disk: { totalGB: 500, usedGB: 220, freeGB: 280, usagePercent: 44 }, }; const body = bodyOfExactly(hostBase, HOSTS_MAX_BODY_BYTES + 1); expect(await pushOversized("/hosts/thinkpad", body, `Bearer ${INGEST_TOKEN}`)).toBe(413); }); }); describe("POST /claude-usage — payload", () => { test.each([ ["truncated JSON", "{not json"], ["an empty body", ""], ])("400 on %s, nothing written", async (_label, body) => { const res = await push({ body }); expect(res.status).toBe(400); expect(res.body.error).toBe("Bad request: body must be valid JSON"); expect(fs.existsSync(usagePath)).toBe(false); }); test("400 on a JSON array", async () => { const res = await push({ body: "[]" }); expect(res.status).toBe(400); expect(res.body.error).toBe("Bad request: body must be a JSON object"); }); test("400 on another schemaVersion", async () => { const res = await push({ body: JSON.stringify({ ...validUsage(), schemaVersion: 2 }) }); expect(res.status).toBe(400); expect(res.body.error).toBe("Bad request: schemaVersion must be 1"); expect(fs.existsSync(usagePath)).toBe(false); }); test("204, the file holds the sanitized snapshot plus a server receivedAt, mode 600", async () => { const before = Date.now(); const sent = { ...validUsage(), extra: "dropped", receivedAt: "2000-01-01T00:00:00.000Z" }; const res = await push({ body: JSON.stringify(sent) }); expect(res.status).toBe(204); const stored = JSON.parse(fs.readFileSync(usagePath, "utf-8")); const { receivedAt, ...snapshot } = stored; expect(snapshot).toEqual(validUsage()); // A client-supplied receivedAt is ignored: the server stamps it. expect(new Date(receivedAt).getTime()).toBeGreaterThanOrEqual(before - 1000); expect(fs.statSync(usagePath).mode & 0o777).toBe(0o600); // Atomic write: no temp file left beside the snapshot. history/ holds the // closed day of this two-day snapshot (#55, claude-usage-history.test.js). expect(fs.readdirSync(path.dirname(usagePath)).sort()).toEqual(["claude-usage.json", "history"]); }); test("a second push replaces the first", async () => { await push(); const second = { ...validUsage(), claudeCodeVersion: "2.1.284" }; expect((await push({ body: JSON.stringify(second) })).status).toBe(204); expect(JSON.parse(fs.readFileSync(usagePath, "utf-8")).claudeCodeVersion).toBe("2.1.284"); }); test("500 when the rename fails after the temp file is written, and the temp file is removed", async () => { // The target is a non-empty directory: mkdir and the temp write succeed, // renameSync fails. Only the cleanup in writeJsonAtomic() removes the // temp file (#54: the mkdir case below never reaches it). fs.mkdirSync(path.join(usagePath, "occupied"), { recursive: true }); const error = vi.spyOn(console, "error").mockImplementation(() => {}); try { const res = await push(); expect(res.status).toBe(500); expect(res.body.error).toBe("Internal error"); } finally { error.mockRestore(); } expect(fs.readdirSync(path.dirname(usagePath))).toEqual(["claude-usage.json"]); expect(fs.readdirSync(usagePath)).toEqual(["occupied"]); }); test("500 when the snapshot cannot be written, and no temp file is left", async () => { // The parent of the file is a regular file: mkdir fails, nothing lands. const blocker = path.join(tmpDir, "blocker"); fs.writeFileSync(blocker, ""); process.env.CLAUDE_USAGE_PATH = path.join(blocker, "claude-usage.json"); await restartServer(); const error = vi.spyOn(console, "error").mockImplementation(() => {}); try { const res = await push(); expect(res.status).toBe(500); expect(res.body.error).toBe("Internal error"); } finally { error.mockRestore(); } }); }); // --- Reading ---------------------------------------------------------------- describe("GET /claude-usage", () => { test("no file -> { status: \"no_data\" }", async () => { const res = await read(); expect(res.status).toBe(200); expect(res.body).toEqual({ status: "no_data" }); }); test("POST then GET: identical snapshot, receivedAt, ageSeconds, stale", async () => { const sent = { ...validUsage(), extra: "dropped" }; expect((await push({ body: JSON.stringify(sent) })).status).toBe(204); const stored = JSON.parse(fs.readFileSync(usagePath, "utf-8")); const res = await read(); expect(res.status).toBe(200); expect(Object.keys(res.body).sort()).toEqual(["ageSeconds", "receivedAt", "snapshot", "stale", "staleAfterSeconds"]); expect(res.body.snapshot).toEqual(validUsage()); expect(res.body.receivedAt).toBe(stored.receivedAt); expect(res.body.ageSeconds).toBeGreaterThanOrEqual(0); expect(res.body.ageSeconds).toBeLessThan(5); expect(res.body.stale).toBe(false); expect(res.body.staleAfterSeconds).toBe(DEFAULT_STALE_SECONDS); }); test.each([ [DEFAULT_STALE_SECONDS - 1, false], [DEFAULT_STALE_SECONDS, false], [DEFAULT_STALE_SECONDS + 1, true], ])("aged %i s -> stale %s (server clock, exact boundary)", async (age, stale) => { writeStored({ ...validUsage(), receivedAt: new Date(FROZEN_NOW - age * 1000).toISOString() }); const res = await frozenRead(); expect(res.body.ageSeconds).toBe(age); expect(res.body.stale).toBe(stale); }); test("CLAUDE_USAGE_STALE_SECONDS moves the boundary", async () => { process.env.CLAUDE_USAGE_STALE_SECONDS = "60"; await restartServer(); writeStored({ ...validUsage(), receivedAt: new Date(FROZEN_NOW - 61 * 1000).toISOString() }); const res = await frozenRead(); expect(res.body.staleAfterSeconds).toBe(60); expect(res.body.stale).toBe(true); }); test.each(["0", "-5", "abc"])("an invalid CLAUDE_USAGE_STALE_SECONDS (%s) falls back to 2700", async (value) => { process.env.CLAUDE_USAGE_STALE_SECONDS = value; await restartServer(); writeStored({ ...validUsage(), receivedAt: new Date(FROZEN_NOW).toISOString() }); expect((await frozenRead()).body.staleAfterSeconds).toBe(DEFAULT_STALE_SECONDS); }); test("a receivedAt in the future reads as age 0, not negative", async () => { writeStored({ ...validUsage(), receivedAt: new Date(FROZEN_NOW + 60000).toISOString() }); const res = await frozenRead(); expect(res.body.ageSeconds).toBe(0); expect(res.body.stale).toBe(false); }); test("the stored file is re-sanitized on the way out", async () => { const raw = validUsage(); raw.extra = "