import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@toolctrl/db/logs.server", () => ({ createLegacyEntityWebhookLog: vi.fn(async (data) => data), createLegacyRowLog: vi.fn(async () => ({ id: "log_1" })), getLegacyEntityWebhooksByAction: vi.fn(), })); vi.mock("~/utils/url.server", () => ({ getBaseURL: vi.fn((request: Request) => new URL(request.url).origin), })); vi.mock("~/utils/helpers/ApiHelper", () => ({ default: { getApiFormat: vi.fn(() => ({ formatted: true })), }, })); import { createLegacyEntityWebhookLog, createLegacyRowLog, getLegacyEntityWebhooksByAction } from "@toolctrl/db/logs.server"; import { createApiRowLog } from "./RowLogService.server"; describe("RowLogService webhook delivery", () => { beforeEach(() => { vi.clearAllMocks(); vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 202 }))); }); it("resolves relative webhook URLs against the request base before validation", async () => { vi.mocked(getLegacyEntityWebhooksByAction).mockResolvedValueOnce([{ id: "webhook_relative", method: "POST", endpoint: "/webhooks/row" }]); await createApiRowLog(new Request("https://tenant.example.com/api/rows"), { tenantId: "tenant_1", action: "created", entity: { id: "entity_1" }, item: { id: "row_1" }, }); expect(fetch).toHaveBeenCalledWith( "https://tenant.example.com/webhooks/row", expect.objectContaining({ method: "POST", redirect: "manual", signal: expect.any(AbortSignal) }) ); expect(createLegacyEntityWebhookLog).toHaveBeenCalledWith({ webhookId: "webhook_relative", logId: "log_1", status: 202 }); }); it("follows safe redirects after validating the redirect target", async () => { vi.stubGlobal( "fetch", vi .fn() .mockResolvedValueOnce(new Response(null, { status: 307, headers: { location: "https://hooks.example.com/row-next" } })) .mockResolvedValueOnce(new Response(null, { status: 202 })) ); vi.mocked(getLegacyEntityWebhooksByAction).mockResolvedValueOnce([{ id: "webhook_redirect", method: "POST", endpoint: "https://hooks.example.com/row" }]); await createApiRowLog(new Request("https://tenant.example.com/api/rows"), { tenantId: "tenant_1", action: "created", entity: { id: "entity_1" }, item: { id: "row_1" }, }); expect(fetch).toHaveBeenNthCalledWith(1, "https://hooks.example.com/row", expect.objectContaining({ method: "POST", redirect: "manual" })); expect(fetch).toHaveBeenNthCalledWith(2, "https://hooks.example.com/row-next", expect.objectContaining({ method: "POST", redirect: "manual" })); expect(createLegacyEntityWebhookLog).toHaveBeenCalledWith({ webhookId: "webhook_redirect", logId: "log_1", status: 202 }); }); it("blocks unsafe redirect targets without following them", async () => { vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 302, headers: { location: "http://127.0.0.1/webhook" } }))); vi.mocked(getLegacyEntityWebhooksByAction).mockResolvedValueOnce([{ id: "webhook_redirect_blocked", method: "POST", endpoint: "https://hooks.example.com/row" }]); await createApiRowLog(new Request("https://tenant.example.com/api/rows"), { tenantId: "tenant_1", action: "created", entity: { id: "entity_1" }, item: { id: "row_1" }, }); expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledWith("https://hooks.example.com/row", expect.objectContaining({ redirect: "manual" })); expect(createLegacyEntityWebhookLog).toHaveBeenCalledWith({ webhookId: "webhook_redirect_blocked", logId: "log_1", status: 0 }); }); it("blocks unsafe webhook URLs and records failed delivery status without fetching", async () => { vi.mocked(getLegacyEntityWebhooksByAction).mockResolvedValueOnce([ { id: "webhook_loopback", method: "POST", endpoint: "http://127.0.0.1/webhook" }, { id: "webhook_relative_localhost", method: "POST", endpoint: "/webhooks/row" }, ]); await createApiRowLog(new Request("https://localhost/api/rows"), { tenantId: "tenant_1", action: "created", entity: { id: "entity_1" }, item: { id: "row_1" }, }); expect(fetch).not.toHaveBeenCalled(); expect(createLegacyRowLog).toHaveBeenCalledTimes(1); expect(createLegacyEntityWebhookLog).toHaveBeenCalledWith({ webhookId: "webhook_loopback", logId: "log_1", status: 0 }); expect(createLegacyEntityWebhookLog).toHaveBeenCalledWith({ webhookId: "webhook_relative_localhost", logId: "log_1", status: 0 }); }); });