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":"