diff --git a/src/alerts/builtins.ts b/src/alerts/builtins.ts index bdc7304b..b89ac3ae 100644 --- a/src/alerts/builtins.ts +++ b/src/alerts/builtins.ts @@ -1,5 +1,6 @@ import { registerAlertChannel, type ChannelDefinition } from "./registry.js"; import { sendWebhookAlert } from "./webhook.js"; +import { sendWebhook2Alert } from "./webhook2.js"; import { SlackChannel } from "./slack.js"; import { sendPagerDutyAlert } from "./pagerduty.js"; @@ -26,6 +27,14 @@ export function registerBuiltinChannels(): void { missingTargetError: "Error: --url is required when --type is webhook.", supportsSigning: true, }, + { + name: "webhook2", + channel: { send: sendWebhook2Alert }, + targetOption: "url", + missingTargetError: + "Error: --url is required when --type is webhook2. The value must be a JSON string: {\"url\":\"https://...\",\"headers\":{},\"timeoutMs\":10000}", + supportsSigning: true, + }, { name: "slack", channel: { send: (target, event) => new SlackChannel(target).send(event) }, diff --git a/src/alerts/webhook2.ts b/src/alerts/webhook2.ts new file mode 100644 index 00000000..1d0ec3da --- /dev/null +++ b/src/alerts/webhook2.ts @@ -0,0 +1,142 @@ +import { createHmac } from "node:crypto"; +import type { AlertEvent } from "./types.js"; +import { getLogger } from "../logging/index.js"; +import { renderAlertTemplate } from "./templates.js"; + +const logger = getLogger().child({ component: "Webhook2Handler" }); + +const DEFAULT_TIMEOUT_MS = 10_000; + +/** + * The JSON-encoded configuration stored in `channel_target` for the webhook2 + * channel. Keeping it as a plain object in the target field lets users supply + * custom headers and a timeout override alongside the destination URL without + * requiring any additional database columns. + * + * Example target value stored in the DB: + * {"url":"https://ops.acme.com/hook","headers":{"X-Api-Key":"s3cr3t"},"timeoutMs":30000} + */ +export interface Webhook2Target { + /** Destination URL for the HTTP POST. */ + url: string; + /** + * Optional HTTP headers to merge into the request. Any key here will + * override the default headers (e.g. Content-Type) when the same key is + * present in both sets. + */ + headers?: Record; + /** + * Optional per-request timeout in milliseconds. Defaults to 10 000 ms + * (same as the original webhook channel) when omitted. + */ + timeoutMs?: number; +} + +/** + * Parse and validate the JSON-encoded `target` string for the webhook2 + * channel. Throws a descriptive error on any validation failure so callers + * get a clear message rather than a generic JSON parse error. + */ +export function parseWebhook2Target(target: string): Webhook2Target { + let parsed: unknown; + try { + parsed = JSON.parse(target); + } catch { + throw new Error( + `webhook2: invalid target — expected a JSON string but received: ${target}`, + ); + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error( + "webhook2: invalid target — expected a JSON object with at least a 'url' field.", + ); + } + + const obj = parsed as Record; + + if (!("url" in obj) || typeof obj["url"] !== "string") { + throw new Error( + "webhook2: invalid target — the 'url' field is required and must be a string.", + ); + } + + return obj as unknown as Webhook2Target; +} + +/** + * Send an AlertEvent to a webhook URL via HTTP POST, with support for + * per-request custom headers and a configurable timeout. + * + * The `target` parameter must be a JSON string conforming to {@link Webhook2Target}. + * + * If a `secret` is provided, the request includes an `X-Sorokeep-Signature` + * header with an HMAC-SHA256 hex digest of the body, allowing receivers to + * verify authenticity — identical signing behaviour to the original webhook + * channel. + * + * Throws on any non-2xx response or network error. + * The caller (dispatcher) is responsible for retry logic via the `delivered` flag. + */ +export async function sendWebhook2Alert( + target: string, + event: AlertEvent, + secret?: string | null, +): Promise { + const config = parseWebhook2Target(target); + const { url, headers: customHeaders = {}, timeoutMs = DEFAULT_TIMEOUT_MS } = config; + + logger.debug(`Sending webhook2 alert to ${url}`, { type: event.type, contractId: event.contractId }); + + // Determine body — support optional Handlebars template (webhook2.hbs) + const customMessage = renderAlertTemplate("webhook2", event); + let body: string; + + // Start with the default Content-Type; callers may override via customHeaders. + const headers: Record = { "Content-Type": "application/json" }; + + if (customMessage !== null) { + body = customMessage; + try { + JSON.parse(customMessage); + } catch { + // Non-JSON template output → fall back to plain text + headers["Content-Type"] = "text/plain"; + } + } else { + body = JSON.stringify(event); + } + + // Merge custom headers *after* setting defaults so callers can override + // Content-Type (or any other default header) when needed. + Object.assign(headers, customHeaders); + + // HMAC signing — computed over the final body string + if (secret) { + const signature = createHmac("sha256", secret).update(body).digest("hex"); + headers["X-Sorokeep-Signature"] = `sha256=${signature}`; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers, + body, + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } + + if (!response.ok) { + throw new Error( + `Webhook2 delivery failed: HTTP ${response.status} from ${url}`, + ); + } + + logger.debug(`Webhook2 alert delivered successfully to ${url}`); +} diff --git a/tests/alerts/builtins.test.ts b/tests/alerts/builtins.test.ts index 2a15e218..395fc01a 100644 --- a/tests/alerts/builtins.test.ts +++ b/tests/alerts/builtins.test.ts @@ -3,6 +3,7 @@ import { _resetRegistryForTesting, getAlertChannel, listAlertChannels } from ".. import type { AlertEvent } from "../../src/alerts/types"; const mockSendWebhookAlert = vi.fn().mockResolvedValue(undefined); +const mockSendWebhook2Alert = vi.fn().mockResolvedValue(undefined); const mockSlackSend = vi.fn().mockResolvedValue(undefined); const mockSendPagerDutyAlert = vi.fn().mockResolvedValue(undefined); const mockSendDiscordAlert = vi.fn().mockResolvedValue(undefined); @@ -11,6 +12,9 @@ const mockSendTelegramAlert = vi.fn().mockResolvedValue(undefined); vi.mock("../../src/alerts/webhook.js", () => ({ sendWebhookAlert: (...args: unknown[]) => mockSendWebhookAlert(...args), })); +vi.mock("../../src/alerts/webhook2.js", () => ({ + sendWebhook2Alert: (...args: unknown[]) => mockSendWebhook2Alert(...args), +})); vi.mock("../../src/alerts/slack.js", () => ({ SlackChannel: class { constructor(public webhookUrl: string) {} @@ -40,25 +44,27 @@ describe("registerBuiltinChannels", () => { registerBuiltinChannels(); }); - it("registers exactly the five built-in channel names", () => { + it("registers exactly the six built-in channel names", () => { const names = listAlertChannels().map((d) => d.name).sort(); - expect(names).toEqual(["discord", "pagerduty", "slack", "telegram", "webhook"]); + expect(names).toEqual(["discord", "pagerduty", "slack", "telegram", "webhook", "webhook2"]); }); it("is idempotent — calling it again does not throw", async () => { const { registerBuiltinChannels } = await import("../../src/alerts/builtins"); expect(() => registerBuiltinChannels()).not.toThrow(); - expect(listAlertChannels()).toHaveLength(5); + expect(listAlertChannels()).toHaveLength(6); }); - it("only webhook supports HMAC signing", () => { + it("only webhook and webhook2 support HMAC signing", () => { + const signingChannels = new Set(["webhook", "webhook2"]); for (const def of listAlertChannels()) { - expect(def.supportsSigning).toBe(def.name === "webhook"); + expect(def.supportsSigning).toBe(signingChannels.has(def.name)); } }); it.each([ ["webhook", "url"], + ["webhook2", "url"], ["slack", "channel"], ["pagerduty", "routingKey"], ["discord", "url"], @@ -72,6 +78,12 @@ describe("registerBuiltinChannels", () => { expect(mockSendWebhookAlert).toHaveBeenCalledWith("https://example.com/hook", event, "secret"); }); + it("webhook2 definition delegates to sendWebhook2Alert", async () => { + const target = JSON.stringify({ url: "https://example.com/hook2" }); + await getAlertChannel("webhook2")!.channel.send(target, event, "secret"); + expect(mockSendWebhook2Alert).toHaveBeenCalledWith(target, event, "secret"); + }); + it("slack definition constructs a SlackChannel with the target and sends", async () => { await getAlertChannel("slack")!.channel.send("#ops", event); expect(mockSlackSend).toHaveBeenCalledWith("#ops", event); @@ -96,6 +108,9 @@ describe("registerBuiltinChannels", () => { expect(getAlertChannel("webhook")?.missingTargetError).toBe( "Error: --url is required when --type is webhook.", ); + expect(getAlertChannel("webhook2")?.missingTargetError).toBe( + "Error: --url is required when --type is webhook2. The value must be a JSON string: {\"url\":\"https://...\",\"headers\":{},\"timeoutMs\":10000}", + ); expect(getAlertChannel("slack")?.missingTargetError).toBe( "Error: --channel is required when --type is slack.", ); diff --git a/tests/alerts/webhook2.test.ts b/tests/alerts/webhook2.test.ts new file mode 100644 index 00000000..979c8472 --- /dev/null +++ b/tests/alerts/webhook2.test.ts @@ -0,0 +1,503 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createHmac } from "node:crypto"; + +// ─── Mock fetch before importing the module under test ──────────────────────── + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +import { sendWebhook2Alert } from "../../src/alerts/webhook2"; +import type { AlertEvent } from "../../src/alerts/types"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeAlertEvent(overrides: Partial = {}): AlertEvent { + return { + type: "threshold_crossed", + severity: "warning", + contractId: "CDEF1234ABCD5678", + contractName: "my-defi-pool", + network: "testnet", + entry: { + keyXdr: "AAAA1234", + type: "instance", + label: "Contract Instance", + }, + threshold: { + configuredLedgers: 20_000, + currentRemainingLedgers: 8_500, + approximateTimeRemaining: "~13h 0m", + }, + firedAtLedger: 2_500_000, + timestamp: "2026-05-21T20:37:08.000Z", + ...overrides, + }; +} + +function makeOkResponse(status = 200): Response { + if (status === 204) { + return new Response(null, { status }); + } + return new Response(JSON.stringify({ ok: true }), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function makeErrorResponse(status: number, body = "Bad Request"): Response { + return new Response(body, { status }); +} + +/** Encode a webhook2 target as a JSON string. */ +function makeTarget(opts: { + url: string; + headers?: Record; + timeoutMs?: number; +}): string { + return JSON.stringify(opts); +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe("sendWebhook2Alert", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.stubGlobal("fetch", mockFetch); + }); + + // ========================================================================= + // 1. TARGET PARSING + // ========================================================================= + describe("Target parsing", () => { + it("parses a minimal target with just a url", async () => { + mockFetch.mockResolvedValue(makeOkResponse()); + const target = makeTarget({ url: "https://ops.example.com/hook" }); + + await sendWebhook2Alert(target, makeAlertEvent()); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [calledUrl] = mockFetch.mock.calls[0]!; + expect(calledUrl).toBe("https://ops.example.com/hook"); + }); + + it("throws a descriptive error when target is not valid JSON", async () => { + await expect( + sendWebhook2Alert("not-json", makeAlertEvent()), + ).rejects.toThrow(/invalid target/i); + }); + + it("throws a descriptive error when target JSON is missing the url field", async () => { + await expect( + sendWebhook2Alert(JSON.stringify({ headers: { "X-Foo": "bar" } }), makeAlertEvent()), + ).rejects.toThrow(/url/i); + }); + + it("throws a descriptive error when url is not a string", async () => { + await expect( + sendWebhook2Alert(JSON.stringify({ url: 42 }), makeAlertEvent()), + ).rejects.toThrow(/url/i); + }); + }); + + // ========================================================================= + // 2. HTTP REQUEST SHAPE (BASE BEHAVIOUR — mirrors webhook.ts) + // ========================================================================= + describe("HTTP request shape", () => { + it("uses HTTP POST method", async () => { + mockFetch.mockResolvedValue(makeOkResponse()); + + await sendWebhook2Alert(makeTarget({ url: "https://example.com/hook" }), makeAlertEvent()); + + const [, options] = mockFetch.mock.calls[0]!; + expect(options.method).toBe("POST"); + }); + + it("defaults Content-Type to application/json", async () => { + mockFetch.mockResolvedValue(makeOkResponse()); + + await sendWebhook2Alert(makeTarget({ url: "https://example.com/hook" }), makeAlertEvent()); + + const [, options] = mockFetch.mock.calls[0]!; + expect(options.headers["Content-Type"]).toBe("application/json"); + }); + + it("sends the full AlertEvent as the JSON body when no custom template", async () => { + mockFetch.mockResolvedValue(makeOkResponse()); + const event = makeAlertEvent({ contractId: "UNIQUE_CONTRACT_ID" }); + + await sendWebhook2Alert(makeTarget({ url: "https://example.com/hook" }), event); + + const [, options] = mockFetch.mock.calls[0]!; + const body = JSON.parse(options.body as string); + expect(body.type).toBe("threshold_crossed"); + expect(body.contractId).toBe("UNIQUE_CONTRACT_ID"); + expect(body.contractName).toBe("my-defi-pool"); + expect(body.network).toBe("testnet"); + expect(body.entry.type).toBe("instance"); + expect(body.threshold.configuredLedgers).toBe(20_000); + expect(body.firedAtLedger).toBe(2_500_000); + }); + + it("sets a signal for abort / timeout control", async () => { + mockFetch.mockResolvedValue(makeOkResponse()); + + await sendWebhook2Alert(makeTarget({ url: "https://example.com/hook" }), makeAlertEvent()); + + const [, options] = mockFetch.mock.calls[0]!; + expect(options.signal).toBeDefined(); + }); + }); + + // ========================================================================= + // 3. CUSTOM HEADERS + // ========================================================================= + describe("Custom headers", () => { + it("merges custom headers into the request", async () => { + mockFetch.mockResolvedValue(makeOkResponse()); + const target = makeTarget({ + url: "https://example.com/hook", + headers: { "X-Api-Key": "secret123", "X-Tenant-Id": "acme" }, + }); + + await sendWebhook2Alert(target, makeAlertEvent()); + + const [, options] = mockFetch.mock.calls[0]!; + expect(options.headers["X-Api-Key"]).toBe("secret123"); + expect(options.headers["X-Tenant-Id"]).toBe("acme"); + }); + + it("custom Content-Type header overrides the default application/json", async () => { + mockFetch.mockResolvedValue(makeOkResponse()); + const target = makeTarget({ + url: "https://example.com/hook", + headers: { "Content-Type": "application/vnd.myapp.alert+json" }, + }); + + await sendWebhook2Alert(target, makeAlertEvent()); + + const [, options] = mockFetch.mock.calls[0]!; + expect(options.headers["Content-Type"]).toBe("application/vnd.myapp.alert+json"); + }); + + it("custom headers do not affect other requests (headers object is not shared)", async () => { + mockFetch.mockResolvedValue(makeOkResponse()); + + // First call with custom headers + await sendWebhook2Alert( + makeTarget({ url: "https://a.example.com/hook", headers: { "X-Req-1": "val1" } }), + makeAlertEvent(), + ); + + // Second call without custom headers + await sendWebhook2Alert( + makeTarget({ url: "https://b.example.com/hook" }), + makeAlertEvent(), + ); + + const [, firstOptions] = mockFetch.mock.calls[0]!; + const [, secondOptions] = mockFetch.mock.calls[1]!; + expect(firstOptions.headers["X-Req-1"]).toBe("val1"); + expect(secondOptions.headers["X-Req-1"]).toBeUndefined(); + }); + + it("sends request with no custom headers when headers field is omitted", async () => { + mockFetch.mockResolvedValue(makeOkResponse()); + const target = makeTarget({ url: "https://example.com/hook" }); + + await sendWebhook2Alert(target, makeAlertEvent()); + + const [, options] = mockFetch.mock.calls[0]!; + // Only Content-Type should be present (and maybe signature header if secret given) + const headerKeys = Object.keys(options.headers); + expect(headerKeys).toContain("Content-Type"); + // No unexpected stray keys from undefined headers object + expect(headerKeys).not.toContain("undefined"); + }); + + it("sends request with no custom headers when headers is an empty object", async () => { + mockFetch.mockResolvedValue(makeOkResponse()); + const target = makeTarget({ url: "https://example.com/hook", headers: {} }); + + await sendWebhook2Alert(target, makeAlertEvent()); + + const [, options] = mockFetch.mock.calls[0]!; + expect(options.headers["Content-Type"]).toBe("application/json"); + }); + }); + + // ========================================================================= + // 4. HMAC SIGNING (same as webhook.ts — signature covers final body) + // ========================================================================= + describe("HMAC signing", () => { + it("does not include X-Sorokeep-Signature when no secret provided", async () => { + mockFetch.mockResolvedValue(makeOkResponse()); + + await sendWebhook2Alert(makeTarget({ url: "https://example.com/hook" }), makeAlertEvent()); + + const [, options] = mockFetch.mock.calls[0]!; + expect(options.headers["X-Sorokeep-Signature"]).toBeUndefined(); + }); + + it("includes X-Sorokeep-Signature when secret is provided", async () => { + mockFetch.mockResolvedValue(makeOkResponse()); + + await sendWebhook2Alert( + makeTarget({ url: "https://example.com/hook" }), + makeAlertEvent(), + "my-secret", + ); + + const [, options] = mockFetch.mock.calls[0]!; + expect(options.headers["X-Sorokeep-Signature"]).toMatch(/^sha256=[a-f0-9]{64}$/); + }); + + it("signature is a valid HMAC-SHA256 of the body", async () => { + mockFetch.mockResolvedValue(makeOkResponse()); + const secret = "test-secret-key"; + const event = makeAlertEvent(); + + await sendWebhook2Alert(makeTarget({ url: "https://example.com/hook" }), event, secret); + + const [, options] = mockFetch.mock.calls[0]!; + const body = options.body as string; + const expectedSig = createHmac("sha256", secret).update(body).digest("hex"); + expect(options.headers["X-Sorokeep-Signature"]).toBe(`sha256=${expectedSig}`); + }); + + it("does not include signature when secret is null", async () => { + mockFetch.mockResolvedValue(makeOkResponse()); + + await sendWebhook2Alert( + makeTarget({ url: "https://example.com/hook" }), + makeAlertEvent(), + null, + ); + + const [, options] = mockFetch.mock.calls[0]!; + expect(options.headers["X-Sorokeep-Signature"]).toBeUndefined(); + }); + + it("HMAC signature is computed after custom Content-Type is resolved (body unchanged)", async () => { + // Verifies that custom headers don't accidentally mutate the body + // used for HMAC computation. + mockFetch.mockResolvedValue(makeOkResponse()); + const secret = "hmac-test"; + const event = makeAlertEvent(); + const target = makeTarget({ + url: "https://example.com/hook", + headers: { "X-Custom": "header-value" }, + }); + + await sendWebhook2Alert(target, event, secret); + + const [, options] = mockFetch.mock.calls[0]!; + const body = options.body as string; + const expectedSig = createHmac("sha256", secret).update(body).digest("hex"); + expect(options.headers["X-Sorokeep-Signature"]).toBe(`sha256=${expectedSig}`); + }); + }); + + // ========================================================================= + // 5. TIMEOUT OVERRIDE + // ========================================================================= + describe("Timeout override", () => { + it("defaults to a 10-second timeout when timeoutMs is not specified", async () => { + vi.useFakeTimers(); + + let aborted = false; + mockFetch.mockImplementation((_url: string, options: any) => { + options.signal.addEventListener("abort", () => { aborted = true; }); + return new Promise(() => {}); // intentionally hangs + }); + + sendWebhook2Alert( + makeTarget({ url: "https://slow.example.com/hook" }), + makeAlertEvent(), + ).catch(() => {}); + + await vi.advanceTimersByTimeAsync(9_999); + expect(aborted).toBe(false); + + await vi.advanceTimersByTimeAsync(2); + expect(aborted).toBe(true); + + vi.useRealTimers(); + }); + + it("respects a custom timeoutMs when provided", async () => { + vi.useFakeTimers(); + + let aborted = false; + mockFetch.mockImplementation((_url: string, options: any) => { + options.signal.addEventListener("abort", () => { aborted = true; }); + return new Promise(() => {}); + }); + + sendWebhook2Alert( + makeTarget({ url: "https://slow.example.com/hook", timeoutMs: 30_000 }), + makeAlertEvent(), + ).catch(() => {}); + + // Should NOT abort at the default 10-second threshold + await vi.advanceTimersByTimeAsync(10_001); + expect(aborted).toBe(false); + + // Should abort after the custom 30-second timeout + await vi.advanceTimersByTimeAsync(20_000); + expect(aborted).toBe(true); + + vi.useRealTimers(); + }); + + it("a short custom timeout fires before the default would", async () => { + vi.useFakeTimers(); + + let aborted = false; + mockFetch.mockImplementation((_url: string, options: any) => { + options.signal.addEventListener("abort", () => { aborted = true; }); + return new Promise(() => {}); + }); + + sendWebhook2Alert( + makeTarget({ url: "https://slow.example.com/hook", timeoutMs: 2_000 }), + makeAlertEvent(), + ).catch(() => {}); + + await vi.advanceTimersByTimeAsync(1_999); + expect(aborted).toBe(false); + + await vi.advanceTimersByTimeAsync(2); + expect(aborted).toBe(true); + + vi.useRealTimers(); + }); + + it("does not abort a request that completes within the timeout", async () => { + vi.useFakeTimers(); + + let aborted = false; + mockFetch.mockImplementation((_url: string, options: any) => { + options.signal.addEventListener("abort", () => { aborted = true; }); + return Promise.resolve(makeOkResponse()); + }); + + await sendWebhook2Alert( + makeTarget({ url: "https://fast.example.com/hook", timeoutMs: 5_000 }), + makeAlertEvent(), + ); + + await vi.advanceTimersByTimeAsync(5_000); + expect(aborted).toBe(false); + + vi.useRealTimers(); + }); + + it("throws when the request is aborted (timeout fires)", async () => { + const abortError = Object.assign( + new Error("The operation was aborted."), + { name: "AbortError" }, + ); + mockFetch.mockRejectedValue(abortError); + + await expect( + sendWebhook2Alert( + makeTarget({ url: "https://slow.example.com/hook", timeoutMs: 1 }), + makeAlertEvent(), + ), + ).rejects.toThrow("aborted"); + }); + }); + + // ========================================================================= + // 6. SUCCESS HANDLING + // ========================================================================= + describe("Success handling", () => { + it("resolves without throwing on 200", async () => { + mockFetch.mockResolvedValue(makeOkResponse(200)); + await expect( + sendWebhook2Alert(makeTarget({ url: "https://example.com/hook" }), makeAlertEvent()), + ).resolves.not.toThrow(); + }); + + it("resolves without throwing on 201", async () => { + mockFetch.mockResolvedValue(makeOkResponse(201)); + await expect( + sendWebhook2Alert(makeTarget({ url: "https://example.com/hook" }), makeAlertEvent()), + ).resolves.not.toThrow(); + }); + + it("resolves without throwing on 204", async () => { + mockFetch.mockResolvedValue(makeOkResponse(204)); + await expect( + sendWebhook2Alert(makeTarget({ url: "https://example.com/hook" }), makeAlertEvent()), + ).resolves.not.toThrow(); + }); + }); + + // ========================================================================= + // 7. ERROR HANDLING + // ========================================================================= + describe("Error handling", () => { + it("throws on 400 Bad Request", async () => { + mockFetch.mockResolvedValue(makeErrorResponse(400)); + + await expect( + sendWebhook2Alert(makeTarget({ url: "https://example.com/hook" }), makeAlertEvent()), + ).rejects.toThrow("400"); + }); + + it("throws on 500 Internal Server Error", async () => { + mockFetch.mockResolvedValue(makeErrorResponse(500)); + + await expect( + sendWebhook2Alert(makeTarget({ url: "https://example.com/hook" }), makeAlertEvent()), + ).rejects.toThrow("500"); + }); + + it("throws when fetch itself rejects (network unreachable)", async () => { + mockFetch.mockRejectedValue(new Error("ECONNREFUSED")); + + await expect( + sendWebhook2Alert(makeTarget({ url: "https://example.com/hook" }), makeAlertEvent()), + ).rejects.toThrow("ECONNREFUSED"); + }); + }); + + // ========================================================================= + // 8. INTERACTION WITH BUILTINS REGISTRY + // ========================================================================= + describe("Registry registration (via builtins)", () => { + it("is registered as 'webhook2' in the builtin registry", async () => { + const { _resetRegistryForTesting, getAlertChannel } = await import("../../src/alerts/registry"); + const { _resetBuiltinRegistrationForTesting, registerBuiltinChannels } = await import("../../src/alerts/builtins"); + + _resetRegistryForTesting(); + _resetBuiltinRegistrationForTesting(); + registerBuiltinChannels(); + + const def = getAlertChannel("webhook2"); + expect(def).toBeDefined(); + expect(def!.name).toBe("webhook2"); + expect(def!.supportsSigning).toBe(true); + }); + + it("webhook2 channel in the registry has a send function", async () => { + const { getAlertChannel } = await import("../../src/alerts/registry"); + + const def = getAlertChannel("webhook2"); + expect(typeof def?.channel.send).toBe("function"); + }); + + it("original 'webhook' channel is still registered after adding webhook2", async () => { + const { getAlertChannel } = await import("../../src/alerts/registry"); + + const webhookDef = getAlertChannel("webhook"); + expect(webhookDef).toBeDefined(); + expect(webhookDef!.name).toBe("webhook"); + }); + }); +});