From 9084d906097ab52aaf5c92dcf7cd7eb6c24f647d Mon Sep 17 00:00:00 2001 From: le king fu Date: Thu, 8 Oct 2026 20:41:30 -0400 Subject: [PATCH] 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 /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 `), where an already archived date always wins. Refs #55 Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 2 +- __tests__/auth.test.js | 14 + __tests__/ci.test.js | 4 +- __tests__/claude-usage-history.test.js | 581 +++++++++++++++++++++++++ __tests__/claude-usage.test.js | 5 +- __tests__/writes.test.js | 25 +- claude-usage-history.js | 315 ++++++++++++++ claude-usage.js | 21 +- index.js | 65 ++- test-curl.sh | 23 + 10 files changed, 1040 insertions(+), 15 deletions(-) create mode 100644 __tests__/claude-usage-history.test.js create mode 100644 claude-usage-history.js 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":"