fix(claude-usage): review findings of the #52/#51/#53 stack #56
4 changed files with 54 additions and 3 deletions
|
|
@ -736,6 +736,23 @@ describe("POST /claude-usage — payload", () => {
|
|||
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");
|
||||
|
|
@ -842,6 +859,11 @@ describe("GET /claude-usage", () => {
|
|||
["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();
|
||||
|
|
|
|||
|
|
@ -869,6 +869,19 @@ describe("POST /hosts/<id> — what actually lands on disk", () => {
|
|||
expect(status).toBe(500);
|
||||
expect(body.error).toBe("Internal error");
|
||||
});
|
||||
|
||||
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 writeHostSnapshot() removes the
|
||||
// temp file (#54: the case above fails at mkdir, before it exists).
|
||||
fs.mkdirSync(path.join(hostsDir, "thinkpad.json", "occupied"), { recursive: true });
|
||||
|
||||
const { status, body } = await ingest("/hosts/thinkpad");
|
||||
expect(status).toBe(500);
|
||||
expect(body.error).toBe("Internal error");
|
||||
expect(fs.readdirSync(hostsDir)).toEqual(["thinkpad.json"]);
|
||||
expect(fs.readdirSync(path.join(hostsDir, "thinkpad.json"))).toEqual(["occupied"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /hosts — freshness boundary", () => {
|
||||
|
|
@ -1010,6 +1023,14 @@ describe("GET /hosts — listing semantics", () => {
|
|||
"a receivedAt that is not a date",
|
||||
JSON.stringify({ id: "thinkpad", ...validSnapshot(), receivedAt: "yesterday" }),
|
||||
],
|
||||
// new Date() reads the next 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({ id: "thinkpad", ...validSnapshot(), receivedAt: "1" })],
|
||||
["a date-only receivedAt", JSON.stringify({ id: "thinkpad", ...validSnapshot(), receivedAt: "2026-10-07" })],
|
||||
[
|
||||
"a receivedAt without an offset",
|
||||
JSON.stringify({ id: "thinkpad", ...validSnapshot(), receivedAt: "2026-10-07T12:00:00" }),
|
||||
],
|
||||
[
|
||||
"a receivedAt that is not a string",
|
||||
JSON.stringify({ id: "thinkpad", ...validSnapshot(), receivedAt: 1700000000000 }),
|
||||
|
|
|
|||
|
|
@ -68,6 +68,13 @@ function cleanTimestamp(value) {
|
|||
return value;
|
||||
}
|
||||
|
||||
// Also used by index.js on the stored receivedAt of both snapshot files: the
|
||||
// server only ever stamps Date#toISOString(), and new Date() alone would
|
||||
// accept "1" or a bare date (#54).
|
||||
function isIsoTimestamp(value) {
|
||||
return cleanTimestamp(value) !== undefined;
|
||||
}
|
||||
|
||||
// The calendar check catches what the regex lets through (2026-02-31).
|
||||
function cleanDayDate(value) {
|
||||
if (typeof value !== "string" || !DAY_DATE_RE.test(value)) return undefined;
|
||||
|
|
@ -176,6 +183,7 @@ function sanitizeClaudeUsage(payload) {
|
|||
|
||||
module.exports = {
|
||||
sanitizeClaudeUsage,
|
||||
isIsoTimestamp,
|
||||
CLAUDE_USAGE_MAX_BODY_BYTES,
|
||||
SCHEMA_VERSION,
|
||||
MAX_DAYS,
|
||||
|
|
|
|||
6
index.js
6
index.js
|
|
@ -13,7 +13,7 @@ const {
|
|||
const path = require("node:path");
|
||||
const { createHash, timingSafeEqual } = require("node:crypto");
|
||||
const { collectMetrics } = require("./metrics.js");
|
||||
const { sanitizeClaudeUsage, CLAUDE_USAGE_MAX_BODY_BYTES } = require("./claude-usage.js");
|
||||
const { sanitizeClaudeUsage, isIsoTimestamp, CLAUDE_USAGE_MAX_BODY_BYTES } = require("./claude-usage.js");
|
||||
|
||||
const PORT = parseInt(process.env.PORT || "3001", 10);
|
||||
const TOKEN = process.env.HEALTH_TOKEN;
|
||||
|
|
@ -500,7 +500,7 @@ function readHostSnapshot(id) {
|
|||
}
|
||||
|
||||
const receivedAt = typeof parsed.receivedAt === "string" ? parsed.receivedAt : null;
|
||||
if (!receivedAt || Number.isNaN(new Date(receivedAt).getTime())) {
|
||||
if (!isIsoTimestamp(receivedAt)) {
|
||||
console.error(`[hosts] snapshot for ${id} has no usable receivedAt`);
|
||||
return null;
|
||||
}
|
||||
|
|
@ -554,7 +554,7 @@ function readClaudeUsage() {
|
|||
}
|
||||
|
||||
const receivedAt = typeof parsed.receivedAt === "string" ? parsed.receivedAt : null;
|
||||
if (!receivedAt || Number.isNaN(new Date(receivedAt).getTime())) {
|
||||
if (!isIsoTimestamp(receivedAt)) {
|
||||
console.error("[claude-usage] snapshot has no usable receivedAt");
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue