vps-health-api/__tests__/claude-usage.test.js
le king fu 9084d90609 feat(server): archive closed Claude usage days by month, serve them on /claude-usage/history
POST /claude-usage now merges every day of the sanitized snapshot except
the last one (today, still open) into <dir of CLAUDE_USAGE_PATH>/history/
YYYY-MM.json, after the snapshot itself is written. The latest snapshot
wins, so a day leaving the 30-day window keeps the value the agent froze
at D+2. Each month file is written atomically (same-dir temp, mode 600,
rename). A month file that cannot be read back is never overwritten, and
an archive failure is logged (months and error codes only) without
changing the 204.

GET /claude-usage/history?from=YYYY-MM&to=YYYY-MM (read token) serves the
archived days, defaulting to the 12 months up to the last archived month,
400 on a bad format, from > to or a span over 36 months. Month files are
re-read through the snapshot's per-day whitelist, now exported from
claude-usage.js as sanitizeDay().

The new module also carries the one-shot import command used by the
backfill (`node claude-usage-history.js --import <file|->`), where an
already archived date always wins.

Refs #55

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:41:30 -04:00

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