102 lines
4.5 KiB
TypeScript
102 lines
4.5 KiB
TypeScript
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 });
|
|
});
|
|
});
|