Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/openstatusHQ/openstatus. ๐ซ Status page with uptime monitoring & API monitoring as code ๐ซ openstatus.dev
bun drizzle-orm monitoring monitoring-as-code nextjs observability on-call open-source shadcn-ui status-page statuspage synthetic-monitoring tinybird turso uptime uptime-checker uptime-monitor
Something went wrong. Try again.
21 kB ยท 646 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647import { expect } from "@std/expect";import { afterEach, beforeEach, describe, test } from "@std/testing/bdd";import { assertSpyCalls, type Stub, stub } from "@std/testing/mock";
import { webhookPayloadSchema } from "../payload";import type { PageUpdate, Subscription } from "../types";import { buildGenericPayload, buildTestPayload, detectWebhookFlavor, sendTestWebhookRequest, sendWebhookNotifications, sendWebhookVerification, validateWebhookConfig,} from "./webhook";
const SLACK_URL = "https://hooks.slack.com/services/T1/B1/XXX";const DISCORD_URL = "https://discord.com/api/webhooks/1/xxx";
let fetchMock: Stub<typeof globalThis>;
function setFetch( impl: ( input: Request | string | URL, init?: RequestInit, ) => Promise<Response>,) { fetchMock?.restore(); fetchMock = stub(globalThis, "fetch", impl);}
function resolveFetch(response: Response) { setFetch(() => Promise.resolve(response));}
function makeSub(overrides: Partial<Subscription> = {}): Subscription { return { id: 1, pageId: 1, pageName: "Test Page", pageSlug: "test", channelType: "webhook", webhookUrl: SLACK_URL, token: "token-123", componentIds: [], ...overrides, };}
function makeUpdate(overrides: Partial<PageUpdate> = {}): PageUpdate { return { id: 1, pageId: 1, title: "Test Incident", status: "investigating", message: "We are investigating.", pageComponentIds: [], pageComponents: ["API", "Database"], date: new Date().toISOString(), ...overrides, };}
beforeEach(() => { fetchMock = stub(globalThis, "fetch", () => Promise.resolve(new Response(null, { status: 200 })), );});
afterEach(() => { fetchMock.restore();});
// โโโ validateWebhookConfig โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
describe("validateWebhookConfig", () => { test("returns valid for an empty config", async () => { const result = await validateWebhookConfig({}); expect(result.valid).toBe(true); });
test("returns valid with optional headers and secret", async () => { const result = await validateWebhookConfig({ headers: [{ key: "Authorization", value: "Bearer token" }], secret: "my-secret", }); expect(result.valid).toBe(true); });
test("returns invalid when a header is missing a key", async () => { const result = await validateWebhookConfig({ headers: [{ key: "", value: "v" }], // key must be min length 1 }); expect(result.valid).toBe(false); });
test("returns invalid for a non-object value", async () => { const result = await validateWebhookConfig("not-an-object"); expect(result.valid).toBe(false); expect(result.error).toBeDefined(); });
test("returns valid with only headers", async () => { const result = await validateWebhookConfig({ headers: [] }); expect(result.valid).toBe(true); });});
// โโโ sendWebhookVerification โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
describe("sendWebhookVerification", () => { test("throws when subscription has no webhookUrl", async () => { const sub = makeSub({ webhookUrl: undefined }); await expect( sendWebhookVerification(sub, "https://example.com/verify/token"), ).rejects.toThrow("Webhook URL is required"); });
test("resolves when fetch returns 200", async () => { resolveFetch(new Response(null, { status: 200 })); const sub = makeSub(); await expect( sendWebhookVerification(sub, "https://example.com/verify/abc"), ).resolves.toBeUndefined(); });
test("throws when fetch returns a non-ok status", async () => { resolveFetch( new Response(null, { status: 500, statusText: "Server Error" }), ); const sub = makeSub(); await expect( sendWebhookVerification(sub, "https://example.com/verify/abc"), ).rejects.toThrow("Webhook verification failed: 500"); });
test("posts to the correct URL with the expected payload", async () => { resolveFetch(new Response(null, { status: 200 })); const sub = makeSub({ token: "my-token", webhookUrl: "https://hooks.example.com/sub", }); await sendWebhookVerification(sub, "https://example.com/verify/my-token");
assertSpyCalls(fetchMock, 1); const [url, init] = fetchMock.calls[0].args; expect(url).toBe("https://hooks.example.com/sub"); expect(init?.method).toBe("POST"); const body = JSON.parse(init?.body as string); expect(body.type).toBe("verification"); expect(body.token).toBe("my-token"); expect(body.verifyUrl).toBe("https://example.com/verify/my-token"); });});
// โโโ sendWebhookNotifications โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
describe("sendWebhookNotifications", () => { test("skips subscriptions without a webhookUrl", async () => { const sub = makeSub({ webhookUrl: undefined }); await sendWebhookNotifications([sub], makeUpdate()); assertSpyCalls(fetchMock, 0); });
test("sends a POST request to each webhook URL", async () => { resolveFetch(new Response(null, { status: 200 })); const sub1 = makeSub({ webhookUrl: "https://hooks.slack.com/services/T1/B1/aaa", }); const sub2 = makeSub({ webhookUrl: "https://hooks.slack.com/services/T2/B2/bbb", });
await sendWebhookNotifications([sub1, sub2], makeUpdate());
assertSpyCalls(fetchMock, 2); const urls = fetchMock.calls.map((c) => c.args[0]); expect(urls).toContain("https://hooks.slack.com/services/T1/B1/aaa"); expect(urls).toContain("https://hooks.slack.com/services/T2/B2/bbb"); });
test("sends a generic JSON payload to non-Slack/Discord URLs", async () => { resolveFetch(new Response(null, { status: 200 })); const generic = makeSub({ id: 101, webhookUrl: "https://example.com/webhook", }); const slack = makeSub({ id: 102, webhookUrl: SLACK_URL });
await sendWebhookNotifications( [generic, slack], makeUpdate({ updateId: 42 }), );
assertSpyCalls(fetchMock, 2); const byUrl = new Map<string, any>( fetchMock.calls.map((c) => { const [url, init] = c.args as [string, RequestInit]; return [url, JSON.parse(init.body as string)]; }), ); expect(byUrl.get("https://example.com/webhook").type).toBe("status_report"); expect(byUrl.get(SLACK_URL).attachments).toBeDefined(); });
test("the status_report body sent over the wire satisfies webhookPayloadSchema (v1)", async () => { resolveFetch(new Response(null, { status: 200 })); const sub = makeSub({ webhookUrl: "https://example.com/webhook" });
await sendWebhookNotifications( [sub], makeUpdate({ status: "investigating", updateId: 42, componentsWithImpact: [ { id: 7, name: "API", impact: "partial_outage" }, ], }), );
const [, init] = fetchMock.calls[0].args; const sentBody = JSON.parse(init?.body as string); const result = webhookPayloadSchema.safeParse(sentBody); expect(result.success).toBe(true); });
test("the maintenance body sent over the wire satisfies webhookPayloadSchema (v1)", async () => { resolveFetch(new Response(null, { status: 200 })); const sub = makeSub({ webhookUrl: "https://example.com/webhook" });
await sendWebhookNotifications( [sub], makeUpdate({ status: "maintenance", startsAt: "2026-04-22T02:00:00Z", endsAt: "2026-04-22T03:00:00Z", pageComponentsWithId: [{ id: 7, name: "API" }], }), );
const [, init] = fetchMock.calls[0].args; const sentBody = JSON.parse(init?.body as string); const result = webhookPayloadSchema.safeParse(sentBody); expect(result.success).toBe(true); });
test("applies custom headers from channelConfig", async () => { resolveFetch(new Response(null, { status: 200 })); const sub = makeSub({ channelConfig: JSON.stringify({ headers: [{ key: "X-Custom-Header", value: "my-value" }], }), });
await sendWebhookNotifications([sub], makeUpdate());
const [, init] = fetchMock.calls[0].args; const headers = init?.headers as Record<string, string>; expect(headers["X-Custom-Header"]).toBe("my-value"); });
test("continues sending to remaining webhooks when one keeps failing", async () => { const failUrl = "https://hooks.slack.com/services/T1/B1/fail"; setFetch((input) => String(input) === failUrl ? Promise.reject(new Error("Network error")) : Promise.resolve(new Response(null, { status: 200 })), );
const sub1 = makeSub({ webhookUrl: failUrl }); const sub2 = makeSub({ webhookUrl: "https://hooks.slack.com/services/T2/B2/succeed", });
await expect( sendWebhookNotifications([sub1, sub2], makeUpdate()), ).resolves.toBeUndefined();
const calls = fetchMock.calls.map((c) => String(c.args[0])); expect(calls).toContain("https://hooks.slack.com/services/T2/B2/succeed"); });
test("retries transient failures then succeeds", async () => { let attempts = 0; setFetch(() => { attempts += 1; return Promise.resolve( attempts < 3 ? new Response(null, { status: 503, statusText: "Service Unavailable", }) : new Response(null, { status: 200 }), ); });
await expect( sendWebhookNotifications([makeSub()], makeUpdate()), ).resolves.toBeUndefined();
expect(attempts).toBe(3); });
test("does not retry non-retryable 4xx responses", async () => { resolveFetch( new Response(null, { status: 400, statusText: "Bad Request" }), );
await sendWebhookNotifications([makeSub()], makeUpdate());
assertSpyCalls(fetchMock, 1); });});
// โโโ detectWebhookFlavor โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
describe("detectWebhookFlavor", () => { test("detects Slack incoming webhook URLs", () => { expect( detectWebhookFlavor("https://hooks.slack.com/services/T00/B00/XXX"), ).toBe("slack"); });
test("detects Discord webhook URLs", () => { expect( detectWebhookFlavor("https://discord.com/api/webhooks/123/abc"), ).toBe("discord"); });
test("falls back to generic for unknown hosts", () => { expect(detectWebhookFlavor("https://example.com/webhook")).toBe("generic"); });});
// โโโ flavor-specific notification payloads โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
describe("sendWebhookNotifications (flavor detection)", () => { test("emits Slack blocks payload for hooks.slack.com URLs", async () => { resolveFetch(new Response(null, { status: 200 })); const sub = makeSub({ webhookUrl: SLACK_URL });
await sendWebhookNotifications([sub], makeUpdate());
const [, init] = fetchMock.calls[0].args; const body = JSON.parse(init?.body as string); expect(body.attachments).toBeDefined(); expect(body.attachments[0].blocks).toBeInstanceOf(Array); });
test("emits Discord embed payload for discord.com URLs", async () => { resolveFetch(new Response(null, { status: 200 })); const sub = makeSub({ webhookUrl: DISCORD_URL });
await sendWebhookNotifications([sub], makeUpdate());
const [, init] = fetchMock.calls[0].args; const body = JSON.parse(init?.body as string); expect(body.embeds).toBeInstanceOf(Array); expect(body.embeds[0].title).toBe("Test Incident"); });
test("Slack payload includes manage/unsubscribe links", async () => { resolveFetch(new Response(null, { status: 200 })); const sub = makeSub({ token: "tok-123", pageSlug: "demo", });
await sendWebhookNotifications([sub], makeUpdate());
const [, init] = fetchMock.calls[0].args; const stringified = init?.body as string; expect(stringified).toContain("https://demo.openstatus.dev/manage/tok-123"); expect(stringified).toContain( "https://demo.openstatus.dev/unsubscribe/tok-123", ); });
test("Slack payload uses custom domain when present", async () => { resolveFetch(new Response(null, { status: 200 })); const sub = makeSub({ token: "tok-123", customDomain: "status.partner.com", });
await sendWebhookNotifications([sub], makeUpdate());
const [, init] = fetchMock.calls[0].args; const stringified = init?.body as string; expect(stringified).toContain("https://status.partner.com/manage/tok-123"); });
test("Slack payload links to the event details and the page origin", async () => { resolveFetch(new Response(null, { status: 200 })); const sub = makeSub({ pageSlug: "demo", pageName: "Demo" });
await sendWebhookNotifications([sub], makeUpdate({ id: 99 }));
const [, init] = fetchMock.calls[0].args; const body = JSON.parse(init?.body as string); const stringified = init?.body as string; // title stays in a plain_text header block expect(body.attachments[0].blocks[0]).toMatchObject({ type: "header", text: { type: "plain_text", text: "Test Incident" }, }); expect(stringified).toContain( "<https://demo.openstatus.dev/events/report/99|View details>", ); expect(stringified).toContain("<https://demo.openstatus.dev|Demo>"); });
test("Discord embed url points at the event; maintenance uses the maintenance path", async () => { resolveFetch(new Response(null, { status: 200 })); const sub = makeSub({ webhookUrl: DISCORD_URL, pageSlug: "demo", pageName: "Demo", });
await sendWebhookNotifications( [sub], makeUpdate({ id: 7, status: "maintenance" }), );
const [, init] = fetchMock.calls[0].args; const body = JSON.parse(init?.body as string); expect(body.embeds[0].url).toBe( "https://demo.openstatus.dev/events/maintenance/7", ); expect(body.embeds[0].fields[1].value).toBe( "[Demo](https://demo.openstatus.dev)", ); });});
// โโโ buildGenericPayload (staged, not yet reachable in production) โโโโโโโโโโโโ
describe("buildGenericPayload", () => { const links = { manageUrl: "https://demo.openstatus.dev/manage/tok-1", unsubscribeUrl: "https://demo.openstatus.dev/unsubscribe/tok-1", };
test("status_report shape", () => { const sub = makeSub({ pageId: 42, pageName: "Acme", pageSlug: "acme" }); const update = makeUpdate({ id: 12, title: "API degraded", status: "investigating", message: "Looking into it.", date: "2026-04-21T09:59:58Z", updateId: 42, componentsWithImpact: [ { id: 7, name: "API", impact: "major_outage" }, { id: 8, name: "Dashboard", impact: "degraded_performance", }, ], });
const payload = buildGenericPayload(update, sub, links) as { version: string; type: string; data: { status_report: Record<string, unknown> }; subscription: { manage_url: string; unsubscribe_url: string }; };
expect(payload.version).toBe("1"); expect(payload.type).toBe("status_report"); expect(payload.data.status_report).toMatchObject({ id: 12, title: "API degraded", url: "https://acme.openstatus.dev/events/report/12", update: { id: 42, status: "investigating", message: "Looking into it.", occurred_at: "2026-04-21T09:59:58Z", }, page: { id: 42, name: "Acme", slug: "acme", url: "https://acme.openstatus.dev", }, components: [ { id: 7, name: "API", impact: "major_outage" }, { id: 8, name: "Dashboard", impact: "degraded_performance", }, ], }); expect(payload.subscription).toEqual({ manage_url: links.manageUrl, unsubscribe_url: links.unsubscribeUrl, }); });
test("maintenance shape (no status field, includes starts_at/ends_at)", () => { const sub = makeSub({ pageId: 42, pageName: "Acme", pageSlug: "acme" }); const update = makeUpdate({ id: 17, title: "DB upgrade", status: "maintenance", message: "Rolling primary.", startsAt: "2026-04-22T02:00:00Z", endsAt: "2026-04-22T03:00:00Z", pageComponentsWithId: [{ id: 7, name: "API" }], });
const payload = buildGenericPayload(update, sub, links) as { version: string; type: string; data: { maintenance: Record<string, unknown> }; };
expect(payload.version).toBe("1"); expect(payload.type).toBe("maintenance"); expect(payload.data.maintenance).toMatchObject({ id: 17, title: "DB upgrade", url: "https://acme.openstatus.dev/events/maintenance/17", message: "Rolling primary.", starts_at: "2026-04-22T02:00:00Z", ends_at: "2026-04-22T03:00:00Z", page: { id: 42, name: "Acme", slug: "acme", url: "https://acme.openstatus.dev", }, // maintenance has no per-component impact: falls back to operational components: [{ id: 7, name: "API", impact: "operational" }], }); expect(payload.data.maintenance).not.toHaveProperty("status"); });
test("output satisfies the canonical webhookPayloadSchema contract", () => { const sub = makeSub({ pageId: 42, pageName: "Acme", pageSlug: "acme" }); const report = buildGenericPayload( makeUpdate({ id: 12, status: "investigating", updateId: 42, componentsWithImpact: [ { id: 7, name: "API", impact: "partial_outage" }, ], }), sub, links, ); const maintenance = buildGenericPayload( makeUpdate({ id: 17, status: "maintenance", startsAt: "2026-04-22T02:00:00Z", endsAt: "2026-04-22T03:00:00Z", pageComponentsWithId: [{ id: 7, name: "API" }], }), sub, links, );
expect(webhookPayloadSchema.safeParse(report).success).toBe(true); expect(webhookPayloadSchema.safeParse(maintenance).success).toBe(true); });});
// โโโ buildTestPayload โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
describe("buildTestPayload", () => { test("Slack flavor returns attachments with blocks", () => { const payload = buildTestPayload("slack") as { attachments: unknown[] }; expect(payload.attachments).toBeInstanceOf(Array); });
test("Discord flavor returns embeds", () => { const payload = buildTestPayload("discord") as { embeds: unknown[] }; expect(payload.embeds).toBeInstanceOf(Array); });
test("generic flavor returns enveloped type='test' JSON", () => { const payload = buildTestPayload("generic") as { version: string; type: string; data: { test: { message: string; timestamp: string } }; }; expect(payload.version).toBe("1"); expect(payload.type).toBe("test"); expect(typeof payload.data.test.message).toBe("string"); expect(typeof payload.data.test.timestamp).toBe("string"); });
test("generic output satisfies the canonical webhookPayloadSchema contract", () => { expect( webhookPayloadSchema.safeParse(buildTestPayload("generic")).success, ).toBe(true); });});
// โโโ sendTestWebhookRequest โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
describe("sendTestWebhookRequest", () => { test("POSTs flavor-appropriate payload", async () => { resolveFetch(new Response(null, { status: 200 })); await sendTestWebhookRequest({ url: "https://example.com/hook", flavor: "generic", });
assertSpyCalls(fetchMock, 1); const [url, init] = fetchMock.calls[0].args; expect(url).toBe("https://example.com/hook"); expect(init?.method).toBe("POST"); const body = JSON.parse(init?.body as string); expect(body.type).toBe("test"); });
test("throws on non-2xx response", async () => { resolveFetch(new Response(null, { status: 404, statusText: "Not Found" })); await expect( sendTestWebhookRequest({ url: "https://example.com/hook", flavor: "generic", }), ).rejects.toThrow(/Test webhook failed: 404/); });
test("passes custom headers", async () => { resolveFetch(new Response(null, { status: 200 })); await sendTestWebhookRequest({ url: "https://example.com/hook", flavor: "generic", headers: { "X-Custom": "abc" }, });
const [, init] = fetchMock.calls[0].args; const headers = init?.headers as Record<string, string>; expect(headers["X-Custom"]).toBe("abc"); });});