import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { logger } from "../lib/logger.js"; import { dispatchWebhooks } from "../lib/webhook-dispatcher.js"; const { sendSlackNotification } = vi.hoisted(() => ({ sendSlackNotification: vi.fn(), })); vi.mock("../lib/slack-notify.js", () => ({ sendSlackNotification, })); vi.mock("../lib/logger.js", () => ({ logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn(), }, })); // Dispatcher now resolves+validates DNS before opening the HTTPS socket. // Mock node:dns/promises so tests do not require real network. vi.mock("node:dns/promises", () => ({ lookup: vi.fn(async (_hostname: string, _opts?: unknown) => [ { address: "93.184.216.34", family: 4 }, ]), })); // Mock node:https so we never open a real socket. The dispatcher calls // https.request(opts, cb); we return a minimal EventEmitter-like stub. const { httpsRequestMock } = vi.hoisted(() => ({ httpsRequestMock: vi.fn(), })); vi.mock("node:https", () => ({ Agent: vi.fn(() => ({})), request: httpsRequestMock, })); describe("webhook dispatcher logging", () => { beforeEach(() => { vi.clearAllMocks(); }); afterEach(() => { vi.unstubAllGlobals(); }); it("logs dispatcher-level failures with event context", async () => { const db = { webhook: { findMany: vi.fn().mockRejectedValue(new Error("database unavailable")), }, }; dispatchWebhooks(db, "estimate.submitted", { id: "estimate_1" }); await vi.waitFor(() => { expect(logger.error).toHaveBeenCalledWith( { err: expect.any(Error), event: "estimate.submitted", }, "Failed to dispatch webhooks", ); }); }); it("logs webhook delivery failures with webhook metadata", async () => { sendSlackNotification.mockRejectedValueOnce(new Error("slack down")); const db = { webhook: { findMany: vi.fn().mockResolvedValue([ { id: "wh_1", name: "Ops Slack", url: "https://hooks.slack.com/services/test", secret: null, events: ["project.created"], }, ]), }, }; dispatchWebhooks(db, "project.created", { id: "project_1", name: "Project One" }); await vi.waitFor(() => { expect(logger.warn).toHaveBeenCalledWith( { err: expect.any(Error), event: "project.created", webhookId: "wh_1", webhookName: "Ops Slack", webhookUrl: "https://hooks.slack.com/services/test", }, "Webhook delivery failed", ); }); }); it("treats non-2xx HTTP webhook responses as delivery failures", async () => { // Stub https.request to deliver a 500 response synchronously via the // response callback, so the dispatcher sees a non-2xx and logs a warn. httpsRequestMock.mockImplementation( (_opts: unknown, cb: (res: { statusCode: number; resume: () => void }) => void) => { queueMicrotask(() => cb({ statusCode: 500, resume: () => {} })); return { on: vi.fn(), write: vi.fn(), end: vi.fn(), destroy: vi.fn(), }; }, ); const db = { webhook: { findMany: vi.fn().mockResolvedValue([ { id: "wh_http_1", name: "Project Webhook", url: "https://example.com/webhooks/project", secret: "secret", events: ["project.created"], }, ]), }, }; dispatchWebhooks(db, "project.created", { id: "project_1", name: "Project One" }); await vi.waitFor(() => { expect(logger.warn).toHaveBeenCalledWith( { err: expect.any(Error), event: "project.created", webhookId: "wh_http_1", webhookName: "Project Webhook", webhookUrl: "https://example.com/webhooks/project", }, "Webhook delivery failed", ); }); expect(httpsRequestMock).toHaveBeenCalledTimes(1); // Verify the pinned IP was passed via the lookup override on the Agent. const firstCall = httpsRequestMock.mock.calls[0]![0] as { host: string; servername: string; agent: { lookup?: unknown }; }; expect(firstCall.host).toBe("example.com"); expect(firstCall.servername).toBe("example.com"); }); it("pins the validated IP via the HTTPS Agent.lookup override (DNS-rebind defence)", async () => { const { Agent } = await import("node:https"); const AgentMock = vi.mocked(Agent); AgentMock.mockClear(); httpsRequestMock.mockImplementation( (_opts: unknown, cb: (res: { statusCode: number; resume: () => void }) => void) => { queueMicrotask(() => cb({ statusCode: 204, resume: () => {} })); return { on: vi.fn(), write: vi.fn(), end: vi.fn(), destroy: vi.fn(), }; }, ); const db = { webhook: { findMany: vi.fn().mockResolvedValue([ { id: "wh_rebind_1", name: "Pinned Webhook", url: "https://example.com/hook", secret: null, events: ["project.created"], }, ]), }, }; dispatchWebhooks(db, "project.created", { id: "p1" }); await vi.waitFor(() => expect(httpsRequestMock).toHaveBeenCalledTimes(1)); expect(AgentMock).toHaveBeenCalledTimes(1); const agentOptions = AgentMock.mock.calls[0]![0] as { lookup?: ( host: string, opts: unknown, cb: (err: null, addr: string, family: number) => void, ) => void; }; expect(typeof agentOptions.lookup).toBe("function"); // Invoke the lookup override to confirm it returns the pre-validated IP, // NOT whatever DNS might be returning right now. const cb = vi.fn(); agentOptions.lookup!("example.com", {}, cb); expect(cb).toHaveBeenCalledWith(null, "93.184.216.34", 4); }); });