mirror of
https://github.com/tiennm99/claude-status-webhook.git
synced 2026-04-17 13:21:01 +00:00
fix: harden webhook reliability, fix bugs, add test suite
- Statuspage webhook always returns 200 to prevent subscriber removal - Fix parseKvKey returning string chatId instead of number - Queue consumer retries on Telegram 5xx instead of acking (prevents message loss) - Fix observability top-level enabled flag (false → true) - Add defensive null checks for webhook payload body - Cache Bot instance per isolate to avoid middleware rebuild per request - Add vitest + @cloudflare/vitest-pool-workers with 31 tests - Document DLQ and KV sharding as declined features
This commit is contained in:
20
test/crypto-utils.test.js
Normal file
20
test/crypto-utils.test.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { timingSafeEqual } from "../src/crypto-utils.js";
|
||||
|
||||
describe("timingSafeEqual", () => {
|
||||
it("returns true for identical strings", async () => {
|
||||
expect(await timingSafeEqual("secret123", "secret123")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for different strings", async () => {
|
||||
expect(await timingSafeEqual("secret123", "wrong")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty vs non-empty", async () => {
|
||||
expect(await timingSafeEqual("", "something")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for both empty", async () => {
|
||||
expect(await timingSafeEqual("", "")).toBe(true);
|
||||
});
|
||||
});
|
||||
124
test/kv-store.test.js
Normal file
124
test/kv-store.test.js
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { env } from "cloudflare:test";
|
||||
import {
|
||||
addSubscriber,
|
||||
removeSubscriber,
|
||||
getSubscriber,
|
||||
updateSubscriberTypes,
|
||||
updateSubscriberComponents,
|
||||
getSubscribersByType,
|
||||
} from "../src/kv-store.js";
|
||||
|
||||
// Each test uses unique chatIds to avoid cross-test interference (miniflare KV persists across tests)
|
||||
describe("kv-store", () => {
|
||||
const kv = env.claude_status;
|
||||
|
||||
describe("addSubscriber / getSubscriber", () => {
|
||||
it("adds subscriber with default types", async () => {
|
||||
await addSubscriber(kv, 100, null);
|
||||
const sub = await getSubscriber(kv, 100, null);
|
||||
expect(sub).toEqual({ types: ["incident", "component"], components: [] });
|
||||
});
|
||||
|
||||
it("adds subscriber with threadId", async () => {
|
||||
await addSubscriber(kv, 101, 456);
|
||||
const sub = await getSubscriber(kv, 101, 456);
|
||||
expect(sub).toEqual({ types: ["incident", "component"], components: [] });
|
||||
});
|
||||
|
||||
it("handles threadId=0 (General topic)", async () => {
|
||||
await addSubscriber(kv, 102, 0);
|
||||
const sub = await getSubscriber(kv, 102, 0);
|
||||
expect(sub).toEqual({ types: ["incident", "component"], components: [] });
|
||||
});
|
||||
|
||||
it("preserves existing data on re-subscribe", async () => {
|
||||
await addSubscriber(kv, 103, null);
|
||||
await updateSubscriberTypes(kv, 103, null, ["incident"]);
|
||||
await addSubscriber(kv, 103, null);
|
||||
const sub = await getSubscriber(kv, 103, null);
|
||||
expect(sub.types).toEqual(["incident"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeSubscriber", () => {
|
||||
it("removes existing subscriber", async () => {
|
||||
await addSubscriber(kv, 200, null);
|
||||
await removeSubscriber(kv, 200, null);
|
||||
const sub = await getSubscriber(kv, 200, null);
|
||||
expect(sub).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateSubscriberTypes", () => {
|
||||
it("updates types for existing subscriber", async () => {
|
||||
await addSubscriber(kv, 300, null);
|
||||
const result = await updateSubscriberTypes(kv, 300, null, ["incident"]);
|
||||
expect(result).toBe(true);
|
||||
const sub = await getSubscriber(kv, 300, null);
|
||||
expect(sub.types).toEqual(["incident"]);
|
||||
});
|
||||
|
||||
it("returns false for non-existent subscriber", async () => {
|
||||
const result = await updateSubscriberTypes(kv, 99999, null, ["incident"]);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateSubscriberComponents", () => {
|
||||
it("sets component filter", async () => {
|
||||
await addSubscriber(kv, 400, null);
|
||||
await updateSubscriberComponents(kv, 400, null, ["API"]);
|
||||
const sub = await getSubscriber(kv, 400, null);
|
||||
expect(sub.components).toEqual(["API"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSubscribersByType", () => {
|
||||
it("filters by event type", async () => {
|
||||
// Use unique IDs unlikely to collide with other tests
|
||||
await addSubscriber(kv, 50001, null);
|
||||
await updateSubscriberTypes(kv, 50001, null, ["incident"]);
|
||||
await addSubscriber(kv, 50002, null);
|
||||
await updateSubscriberTypes(kv, 50002, null, ["component"]);
|
||||
|
||||
const incident = await getSubscribersByType(kv, "incident");
|
||||
const incidentIds = incident.map((s) => s.chatId);
|
||||
expect(incidentIds).toContain(50001);
|
||||
expect(incidentIds).not.toContain(50002);
|
||||
|
||||
const component = await getSubscribersByType(kv, "component");
|
||||
const componentIds = component.map((s) => s.chatId);
|
||||
expect(componentIds).toContain(50002);
|
||||
expect(componentIds).not.toContain(50001);
|
||||
});
|
||||
|
||||
it("filters by component name", async () => {
|
||||
await addSubscriber(kv, 60001, null);
|
||||
await updateSubscriberComponents(kv, 60001, null, ["API"]);
|
||||
await addSubscriber(kv, 60002, null); // no component filter = all
|
||||
|
||||
const results = await getSubscribersByType(kv, "component", "API");
|
||||
const ids = results.map((s) => s.chatId);
|
||||
expect(ids).toContain(60001);
|
||||
expect(ids).toContain(60002);
|
||||
});
|
||||
|
||||
it("excludes non-matching component filter", async () => {
|
||||
await addSubscriber(kv, 70001, null);
|
||||
await updateSubscriberComponents(kv, 70001, null, ["Console"]);
|
||||
|
||||
const results = await getSubscribersByType(kv, "component", "API");
|
||||
const ids = results.map((s) => s.chatId);
|
||||
expect(ids).not.toContain(70001);
|
||||
});
|
||||
|
||||
it("returns chatId as number", async () => {
|
||||
await addSubscriber(kv, 80001, null);
|
||||
const results = await getSubscribersByType(kv, "incident");
|
||||
const match = results.find((s) => s.chatId === 80001);
|
||||
expect(match).toBeDefined();
|
||||
expect(typeof match.chatId).toBe("number");
|
||||
});
|
||||
});
|
||||
});
|
||||
79
test/queue-consumer.test.js
Normal file
79
test/queue-consumer.test.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { handleQueue } from "../src/queue-consumer.js";
|
||||
|
||||
/**
|
||||
* Create a mock queue message with ack/retry tracking
|
||||
*/
|
||||
function mockMessage(body) {
|
||||
return {
|
||||
body,
|
||||
ack: vi.fn(),
|
||||
retry: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleQueue", () => {
|
||||
let env;
|
||||
|
||||
beforeEach(() => {
|
||||
env = {
|
||||
BOT_TOKEN: "test-token",
|
||||
claude_status: {
|
||||
delete: vi.fn(),
|
||||
},
|
||||
};
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("acks on successful send", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, status: 200 }));
|
||||
const msg = mockMessage({ chatId: 123, html: "<b>test</b>" });
|
||||
await handleQueue({ messages: [msg] }, env);
|
||||
expect(msg.ack).toHaveBeenCalled();
|
||||
expect(msg.retry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes subscriber and acks on 403", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 403 }));
|
||||
const msg = mockMessage({ chatId: 123, threadId: null, html: "<b>test</b>" });
|
||||
await handleQueue({ messages: [msg] }, env);
|
||||
expect(msg.ack).toHaveBeenCalled();
|
||||
expect(env.claude_status.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries on 429 rate limit", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
headers: new Headers({ "Retry-After": "5" }),
|
||||
})
|
||||
);
|
||||
const msg = mockMessage({ chatId: 123, html: "<b>test</b>" });
|
||||
await handleQueue({ messages: [msg] }, env);
|
||||
expect(msg.retry).toHaveBeenCalled();
|
||||
expect(msg.ack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries on 5xx server error", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 502 }));
|
||||
const msg = mockMessage({ chatId: 123, html: "<b>test</b>" });
|
||||
await handleQueue({ messages: [msg] }, env);
|
||||
expect(msg.retry).toHaveBeenCalled();
|
||||
expect(msg.ack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries on network error", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network fail")));
|
||||
const msg = mockMessage({ chatId: 123, html: "<b>test</b>" });
|
||||
await handleQueue({ messages: [msg] }, env);
|
||||
expect(msg.retry).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips malformed messages", async () => {
|
||||
const msg = mockMessage({ chatId: null, html: null });
|
||||
await handleQueue({ messages: [msg] }, env);
|
||||
expect(msg.ack).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
63
test/status-fetcher.test.js
Normal file
63
test/status-fetcher.test.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
escapeHtml,
|
||||
humanizeStatus,
|
||||
statusIndicator,
|
||||
formatComponentLine,
|
||||
formatOverallStatus,
|
||||
} from "../src/status-fetcher.js";
|
||||
|
||||
describe("escapeHtml", () => {
|
||||
it("escapes HTML special chars", () => {
|
||||
expect(escapeHtml('<script>"alert&"</script>')).toBe(
|
||||
"<script>"alert&"</script>"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty string for null/undefined", () => {
|
||||
expect(escapeHtml(null)).toBe("");
|
||||
expect(escapeHtml(undefined)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("humanizeStatus", () => {
|
||||
it("maps known statuses", () => {
|
||||
expect(humanizeStatus("operational")).toBe("Operational");
|
||||
expect(humanizeStatus("major_outage")).toBe("Major Outage");
|
||||
expect(humanizeStatus("resolved")).toBe("Resolved");
|
||||
});
|
||||
|
||||
it("returns raw string for unknown status", () => {
|
||||
expect(humanizeStatus("custom_status")).toBe("custom_status");
|
||||
});
|
||||
});
|
||||
|
||||
describe("statusIndicator", () => {
|
||||
it("returns green check for operational", () => {
|
||||
expect(statusIndicator("operational")).toBe("\u2705");
|
||||
});
|
||||
|
||||
it("returns question mark for unknown", () => {
|
||||
expect(statusIndicator("unknown_status")).toBe("\u2753");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatComponentLine", () => {
|
||||
it("formats component with indicator and escaped name", () => {
|
||||
const line = formatComponentLine({ name: "API", status: "operational" });
|
||||
expect(line).toContain("\u2705");
|
||||
expect(line).toContain("<b>API</b>");
|
||||
expect(line).toContain("Operational");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatOverallStatus", () => {
|
||||
it("maps known indicators", () => {
|
||||
expect(formatOverallStatus("none")).toContain("All Systems Operational");
|
||||
expect(formatOverallStatus("critical")).toContain("Critical System Outage");
|
||||
});
|
||||
|
||||
it("returns raw value for unknown indicator", () => {
|
||||
expect(formatOverallStatus("custom")).toBe("custom");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user