vps-health-api/__tests__/claude-usage.test.js
le king fu e2a24d768b fix(server): require a full ISO receivedAt on read, cover the temp-file cleanup
readClaudeUsage() and readHostSnapshot() validated the stored receivedAt
with new Date(), which accepts "1" or a bare date. Both now go through
isIsoTimestamp(), exported by claude-usage.js and built on the sanitizer's
own check, so no regex is duplicated.

The existing 500 tests failed at mkdir, before the temp file existed, so
removing the unlinkSync() cleanup of either writer left the suite green. Two
new cases make the rename fail after the temp write (the target is a
non-empty directory) and require that no temp file is left.

Refs #54

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:25:26 -04:00

888 lines
34 KiB
JavaScript

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", "<html>"],
["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/<id>", 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/<id> 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.
expect(fs.readdirSync(path.dirname(usagePath))).toEqual(["claude-usage.json"]);
});
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 writeClaudeUsage() 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 = "<script>";
raw.limits.detail = "HTTP 500 body";
raw.projects[0].work = -1;
writeStored({ ...raw, receivedAt: new Date(FROZEN_NOW).toISOString() });
const { body } = await frozenRead();
expect(body.snapshot).not.toHaveProperty("extra");
expect(body.snapshot.limits).not.toHaveProperty("detail");
expect(body.snapshot.projects[0]).not.toHaveProperty("work");
expect(body.snapshot).not.toHaveProperty("receivedAt");
});
describe("degrades to no_data", () => {
let error;
beforeEach(() => {
error = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
error.mockRestore();
});
test.each([
["a corrupt file", "{corrupt"],
["an array", "[]"],
["another schemaVersion", JSON.stringify({ ...validUsage(), schemaVersion: 2, receivedAt: "2026-09-27T19:00:00.000Z" })],
["no receivedAt", JSON.stringify(validUsage())],
["an unusable receivedAt", JSON.stringify({ ...validUsage(), receivedAt: "never" })],
// new Date() reads all three; the server only ever stamps a full ISO
// timestamp with an offset, so anything else was not written by it (#54).
["a receivedAt of \"1\"", JSON.stringify({ ...validUsage(), receivedAt: "1" })],
["a date-only receivedAt", JSON.stringify({ ...validUsage(), receivedAt: "2026-10-07" })],
["a receivedAt without an offset", JSON.stringify({ ...validUsage(), receivedAt: "2026-10-07T12:00:00" })],
])("on %s", async (_label, raw) => {
writeStored(raw);
const res = await read();
expect(res.status).toBe(200);
expect(res.body).toEqual({ status: "no_data" });
});
test("when the path is a directory", async () => {
fs.mkdirSync(usagePath, { recursive: true });
const res = await read();
expect(res.body).toEqual({ status: "no_data" });
});
});
});
describe("the server image", () => {
test("the Dockerfile COPY line ships claude-usage.js", () => {
const dockerfile = fs.readFileSync(path.join(__dirname, "..", "Dockerfile"), "utf-8");
const copy = dockerfile.split("\n").find((line) => /^\s*COPY\s/.test(line));
expect(copy.trim().split(/\s+/)).toContain("claude-usage.js");
});
});