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>
889 lines
34 KiB
JavaScript
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");
|
|
});
|
|
});
|