diff --git a/Dockerfile b/Dockerfile
index 1324920..bdac9eb 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,6 +1,6 @@
FROM node:22-alpine
WORKDIR /app
-COPY package.json index.js metrics.js claude-usage.js ./
+COPY package.json index.js metrics.js claude-usage.js claude-usage-history.js ./
EXPOSE 3001
USER node
CMD ["node", "index.js"]
diff --git a/__tests__/auth.test.js b/__tests__/auth.test.js
index e5549c7..b52e11a 100644
--- a/__tests__/auth.test.js
+++ b/__tests__/auth.test.js
@@ -26,6 +26,9 @@ const ROUTES = [
"/defenseurs/state",
"/reports/scans",
"/hosts",
+ // #55. GET /claude-usage is not listed: POST /claude-usage exists, so the
+ // "404 on POST" row below does not hold for it (claude-usage.test.js covers it).
+ "/claude-usage/history",
];
// Ingest token, distinct from the read token on purpose: a read token must not
@@ -83,6 +86,7 @@ beforeEach(async () => {
process.env.DEFENSEURS_FINDINGS_STATE_PATH = path.join(tmpDir, "findings-state.json");
process.env.HOSTS_DIR = path.join(tmpDir, "hosts");
process.env.HOSTS_ALLOWED_IDS = "thinkpad";
+ process.env.CLAUDE_USAGE_PATH = path.join(tmpDir, "claude-usage", "claude-usage.json");
// Configured on purpose: an unset ingest token answers 503 before the header
// is ever read, which would hide the 401 these tests are here to pin.
process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN;
@@ -98,6 +102,10 @@ afterEach(async () => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
+afterAll(() => {
+ delete process.env.CLAUDE_USAGE_PATH;
+});
+
describe("auth gate — missing Authorization header", () => {
test.each(ROUTES)("401 on GET %s with no Authorization header", async (route) => {
const { status, body } = await request(route, { auth: null });
@@ -306,6 +314,12 @@ describe("routing", () => {
expect(body.error).toBe("Not found");
});
+ test("404 (not 401) on POST /claude-usage/history without Authorization", async () => {
+ const { status, body } = await request("/claude-usage/history", { method: "POST", auth: null });
+ expect(status).toBe(404);
+ expect(body.error).toBe("Not found");
+ });
+
test("404 (not 401) on POST /health without Authorization", async () => {
const { status, body } = await request("/health", { method: "POST", auth: null });
expect(status).toBe(404);
diff --git a/__tests__/ci.test.js b/__tests__/ci.test.js
index 09a27a5..995c256 100644
--- a/__tests__/ci.test.js
+++ b/__tests__/ci.test.js
@@ -342,7 +342,7 @@ describe("test-curl.sh", () => {
expect(proxied).toEqual([]);
expect(code, stdout + stderr).toBe(0);
// Every case ran against the API: a count, so a script cut short fails.
- expect(stdout).toMatch(/=== Results: 45 passed, 0 failed ===/);
+ expect(stdout).toMatch(/=== Results: 51 passed, 0 failed ===/);
} finally {
await new Promise((resolve) => proxy.close(resolve));
}
@@ -361,7 +361,7 @@ describe("test-curl.sh", () => {
for (const { code, stdout, stderr } of results) {
expect(code, stdout + stderr).toBe(0);
- expect(stdout).toMatch(/=== Results: 45 passed, 0 failed ===/);
+ expect(stdout).toMatch(/=== Results: 51 passed, 0 failed ===/);
}
} finally {
for (const port of ports) expect(await released(port)).toBe(true);
diff --git a/__tests__/claude-usage-history.test.js b/__tests__/claude-usage-history.test.js
new file mode 100644
index 0000000..8e0c04b
--- /dev/null
+++ b/__tests__/claude-usage-history.test.js
@@ -0,0 +1,581 @@
+const http = require("node:http");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const { spawnSync } = require("node:child_process");
+
+// Claude usage history (issue #55): the closed days of the snapshot, kept past
+// its 30-day window in one file per month under
/history/.
+//
+// Pinned here:
+// * the pure helpers of claude-usage-history.js: the two merge rules, the
+// grouping by the month of the date string, the whitelist re-read of a
+// month file, and the range rules of the read route;
+// * the archive written by POST /claude-usage: every day but the last, the
+// latest snapshot winning, an unreadable month never overwritten, and an
+// archive failure that never changes the 204;
+// * GET /claude-usage/history: defaults, bounds, degraded months;
+// * the one-shot import command, run as a real process: the archived date
+// wins, and an invalid import file writes nothing.
+
+const TOKEN = "test-token";
+const CLAUDE_TOKEN = "test-claude-token";
+const MODULE = path.join(__dirname, "..", "claude-usage-history.js");
+const FROZEN_NOW = Date.UTC(2026, 9, 8, 19, 0, 0); // 2026-10-08
+
+let tmpDir;
+let usagePath;
+let historyPath;
+let server;
+let baseUrl;
+
+function day(date, output = 1) {
+ return {
+ date,
+ models: { "claude-opus-5-5": { input: 1, output, cacheCreation: 0, cacheRead: 0, requests: 1 } },
+ };
+}
+
+function snapshotWith(days) {
+ return {
+ schemaVersion: 1,
+ generatedAt: "2026-10-02T19:00:00.000Z",
+ timeZone: "America/Toronto",
+ limits: { status: "ok", fetchedAt: "2026-10-02T19:00:00.000Z", items: [] },
+ days,
+ projects: [{ name: "proj", work: 3, cacheRead: 4, requests: 1 }],
+ };
+}
+
+function monthFile(month) {
+ return path.join(historyPath, `${month}.json`);
+}
+
+function readMonthFile(month) {
+ return JSON.parse(fs.readFileSync(monthFile(month), "utf-8"));
+}
+
+function writeMonthFile(month, content) {
+ fs.mkdirSync(historyPath, { recursive: true });
+ fs.writeFileSync(monthFile(month), typeof content === "string" ? content : JSON.stringify(content));
+}
+
+function freshHistory() {
+ delete require.cache[require.resolve("../claude-usage-history.js")];
+ delete require.cache[require.resolve("../claude-usage.js")];
+ return require("../claude-usage-history.js");
+}
+
+function startServer() {
+ for (const mod of ["../index.js", "../claude-usage.js", "../claude-usage-history.js"]) {
+ delete require.cache[require.resolve(mod)];
+ }
+ 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 request(route, { method = "GET", auth = `Bearer ${TOKEN}`, body } = {}) {
+ const headers = {};
+ 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(days) {
+ return request("/claude-usage", {
+ method: "POST",
+ auth: `Bearer ${CLAUDE_TOKEN}`,
+ body: JSON.stringify(snapshotWith(days)),
+ });
+}
+
+async function history(query = "") {
+ const realNow = Date.now;
+ Date.now = () => FROZEN_NOW;
+ try {
+ return await request(`/claude-usage/history${query}`);
+ } finally {
+ Date.now = realNow;
+ }
+}
+
+// Every file under dir, relative, sorted: what a case may and may not touch.
+function tree(dir) {
+ if (!fs.existsSync(dir)) return [];
+ return fs
+ .readdirSync(dir, { recursive: true })
+ .map(String)
+ .sort();
+}
+
+beforeEach(async () => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-history-test-"));
+ usagePath = path.join(tmpDir, "claude-usage", "claude-usage.json");
+ historyPath = path.join(tmpDir, "claude-usage", "history");
+
+ process.env.HEALTH_TOKEN = TOKEN;
+ process.env.HOSTS_INGEST_TOKEN = "test-ingest-token";
+ process.env.CLAUDE_USAGE_INGEST_TOKEN = CLAUDE_TOKEN;
+ process.env.CLAUDE_USAGE_PATH = usagePath;
+ 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 () => {
+ vi.restoreAllMocks();
+ await stopServer();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+});
+
+afterAll(() => {
+ delete process.env.CLAUDE_USAGE_INGEST_TOKEN;
+ delete process.env.CLAUDE_USAGE_PATH;
+});
+
+// --- Pure helpers -----------------------------------------------------------
+
+describe("month arithmetic", () => {
+ test("addMonths crosses years both ways", () => {
+ const { addMonths } = freshHistory();
+ expect(addMonths("2026-10", -11)).toBe("2025-11");
+ expect(addMonths("2026-12", 1)).toBe("2027-01");
+ expect(addMonths("2026-01", -1)).toBe("2025-12");
+ expect(addMonths("2026-01", -36)).toBe("2023-01");
+ });
+
+ test("monthSpan counts both ends", () => {
+ const { monthSpan } = freshHistory();
+ expect(monthSpan("2026-01", "2026-01")).toBe(1);
+ expect(monthSpan("2026-01", "2026-12")).toBe(12);
+ expect(monthSpan("2024-01", "2026-12")).toBe(36);
+ });
+});
+
+describe("mergeDays", () => {
+ test("the latest value wins by default, result sorted, inputs untouched", () => {
+ const { mergeDays } = freshHistory();
+ const existing = [day("2026-09-02", 1), day("2026-09-01", 1)];
+ const incoming = [day("2026-09-03", 3), day("2026-09-02", 2)];
+ const before = JSON.stringify([existing, incoming]);
+ const merged = mergeDays(existing, incoming);
+ expect(merged.map((d) => [d.date, d.models["claude-opus-5-5"].output])).toEqual([
+ ["2026-09-01", 1],
+ ["2026-09-02", 2],
+ ["2026-09-03", 3],
+ ]);
+ expect(JSON.stringify([existing, incoming])).toBe(before);
+ });
+
+ test("preferExisting keeps an archived date and adds only the missing ones", () => {
+ const { mergeDays } = freshHistory();
+ const merged = mergeDays([day("2026-09-02", 1)], [day("2026-09-02", 9), day("2026-09-01", 9)], {
+ preferExisting: true,
+ });
+ expect(merged.map((d) => [d.date, d.models["claude-opus-5-5"].output])).toEqual([
+ ["2026-09-01", 9],
+ ["2026-09-02", 1],
+ ]);
+ });
+
+ test("a date repeated in the incoming list: the later entry wins", () => {
+ const { mergeDays } = freshHistory();
+ const merged = mergeDays([], [day("2026-09-01", 1), day("2026-09-01", 2)]);
+ expect(merged).toEqual([day("2026-09-01", 2)]);
+ });
+});
+
+describe("groupByMonth", () => {
+ test("groups by the YYYY-MM prefix of the date string", () => {
+ const { groupByMonth } = freshHistory();
+ const months = groupByMonth([day("2026-10-31"), day("2026-11-01"), day("2026-10-01")]);
+ expect([...months.keys()]).toEqual(["2026-10", "2026-11"]);
+ expect(months.get("2026-10").map((d) => d.date)).toEqual(["2026-10-31", "2026-10-01"]);
+ });
+});
+
+describe("sanitizeMonthFile — the snapshot's whitelist on the way back", () => {
+ test("a clean file comes back identical", () => {
+ const { sanitizeMonthFile } = freshHistory();
+ const file = { schemaVersion: 1, month: "2026-09", days: [day("2026-09-01"), day("2026-09-02")] };
+ expect(sanitizeMonthFile(file, "2026-09")).toEqual(file.days);
+ });
+
+ test("unknown fields, __proto__, invalid values and foreign dates are dropped", () => {
+ const { sanitizeMonthFile } = freshHistory();
+ const raw =
+ '{"schemaVersion":1,"month":"2026-09","extra":"x","days":[' +
+ '{"date":"2026-09-01","extra":"