diff --git a/apps/api/src/lib/webhook-delivery.ts b/apps/api/src/lib/webhook-delivery.ts new file mode 100644 index 00000000..42005ab7 --- /dev/null +++ b/apps/api/src/lib/webhook-delivery.ts @@ -0,0 +1,61 @@ +interface DeliveryOptions { + maxRetries?: number; + initialDelayMs?: number; + timeoutMs?: number; +} + +interface DeliveryResult { + success: boolean; + statusCode?: number; + error?: string; + attempts: number; +} + +export async function deliverWebhook( + url: string, + authHeader: string, + events: Record[], + options: DeliveryOptions = {}, +): Promise { + const { maxRetries = 3, initialDelayMs = 1000, timeoutMs = 30_000 } = options; + + const payload = JSON.stringify({ + source: "snapotter", + version: "1", + events, + }); + + const headers: Record = { "Content-Type": "application/json" }; + if (authHeader) headers["Authorization"] = authHeader; + + let lastError: string | undefined; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + if (attempt > 0) { + const delay = initialDelayMs * 2 ** (attempt - 1); + await new Promise((r) => setTimeout(r, delay)); + } + + try { + const response = await fetch(url, { + method: "POST", + headers, + body: payload, + signal: AbortSignal.timeout(timeoutMs), + }); + + if (response.ok) { + return { success: true, statusCode: response.status, attempts: attempt + 1 }; + } + + lastError = `HTTP ${response.status}`; + // Don't retry 4xx errors (client errors = won't succeed on retry) + if (response.status >= 400 && response.status < 500) { + return { success: false, statusCode: response.status, error: lastError, attempts: attempt + 1 }; + } + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + } + } + + return { success: false, error: lastError, attempts: maxRetries + 1 }; +} diff --git a/tests/unit/api/webhook-delivery.test.ts b/tests/unit/api/webhook-delivery.test.ts new file mode 100644 index 00000000..6bb69fa3 --- /dev/null +++ b/tests/unit/api/webhook-delivery.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +describe("webhook delivery", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("delivers a batch of events", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js"); + const result = await deliverWebhook( + "https://siem.example.com/input", + "Bearer test-token", + [{ event: "LOGIN_SUCCESS", timestamp: "2026-01-01T00:00:00Z" }], + ); + + expect(result.success).toBe(true); + expect(result.attempts).toBe(1); + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, opts] = fetchMock.mock.calls[0]; + expect(url).toBe("https://siem.example.com/input"); + expect(opts.headers["Authorization"]).toBe("Bearer test-token"); + const body = JSON.parse(opts.body); + expect(body.source).toBe("snapotter"); + expect(body.version).toBe("1"); + expect(body.events).toHaveLength(1); + }); + + it("retries on server error", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ ok: false, status: 502 }) + .mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js"); + const result = await deliverWebhook("https://siem.example.com/input", "", [{ event: "test" }], { + maxRetries: 3, + initialDelayMs: 1, + }); + + expect(result.success).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("does not retry on 4xx client errors", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 401 }); + vi.stubGlobal("fetch", fetchMock); + + const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js"); + const result = await deliverWebhook("https://siem.example.com/input", "", [{ event: "test" }], { + maxRetries: 3, + initialDelayMs: 1, + }); + + expect(result.success).toBe(false); + expect(result.statusCode).toBe(401); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("retries on network errors", async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error("connection refused")) + .mockRejectedValueOnce(new Error("connection refused")) + .mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js"); + const result = await deliverWebhook("https://siem.example.com/input", "", [{ event: "test" }], { + maxRetries: 3, + initialDelayMs: 1, + }); + + expect(result.success).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("fails after exhausting retries", async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error("down")); + vi.stubGlobal("fetch", fetchMock); + + const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js"); + const result = await deliverWebhook("https://siem.example.com/input", "", [{ event: "test" }], { + maxRetries: 2, + initialDelayMs: 1, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe("down"); + expect(result.attempts).toBe(3); // initial + 2 retries + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("omits Authorization header when auth is empty", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js"); + await deliverWebhook("https://example.com", "", [{ event: "test" }]); + + const headers = fetchMock.mock.calls[0][1].headers; + expect(headers["Authorization"]).toBeUndefined(); + }); +});