feat(api): add webhook delivery utility and card event integration (#392)
* feat(api): add webhook delivery utility and card event integration Add the core webhook delivery logic and wire it into card mutations: - Add sendWebhookToUrl() with HMAC-SHA256 signing, 10s timeout - Add sendWebhooksForWorkspace() for fan-out delivery (fire-and-forget) - Add createCardWebhookPayload() for building webhook payloads - Fire webhooks on card create, update, move, and delete events - Add unit tests for webhook utility functions Depends on #391 (DB schema & repository). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(api): use correct boardId in webhook payloads and add rejection safety - Fix bug where workspaceId was incorrectly passed as boardId in all webhook payloads — now uses board's publicId via boardPublicId - Replace void sendWebhooksForWorkspace() with .catch() to prevent unhandled promise rejections if the DB query inside fails Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(api): add SSRF protection to webhook delivery Block webhook URLs targeting internal networks: - Require HTTPS (reject HTTP) - Block localhost, 127.0.0.1, ::1, 0.0.0.0 - Block cloud metadata endpoints (169.254.169.254, metadata.google.internal) - Block private IP ranges (10.x, 172.16-31.x, 192.168.x) - Add tests for all blocked URL patterns Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(api): use WebhookEvent type from schema instead of duplicating Replace the hardcoded WebhookEventType union with the canonical WebhookEvent type from @kan/db/schema, addressing reviewer feedback on PR #392. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(api): improve webhook delivery safety and validation Cherry-pick delivery-related changes from b2cc9ac: - Extract URL validation into reusable webhookUrlSchema zod validator for SSRF checks - Wrap sendWebhooksForWorkspace in try/catch to prevent unhandled promise rejections - Document SSRF risk mitigation on sendWebhookToUrl - Add corresponding tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
528
packages/api/src/utils/webhook.test.ts
Normal file
528
packages/api/src/utils/webhook.test.ts
Normal file
@@ -0,0 +1,528 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@kan/db/repository/webhook.repo", () => ({
|
||||
getActiveByWorkspaceId: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as webhookRepo from "@kan/db/repository/webhook.repo";
|
||||
import {
|
||||
sendWebhookToUrl,
|
||||
sendWebhooksForWorkspace,
|
||||
createCardWebhookPayload,
|
||||
webhookUrlSchema,
|
||||
type WebhookPayload,
|
||||
} from "./webhook";
|
||||
|
||||
const mockGetActiveByWorkspaceId = webhookRepo.getActiveByWorkspaceId as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe("webhook utilities", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2024-01-15T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("createCardWebhookPayload", () => {
|
||||
it("creates a payload with required card fields", () => {
|
||||
const payload = createCardWebhookPayload(
|
||||
"card.created",
|
||||
{
|
||||
id: "card-123",
|
||||
title: "Test Card",
|
||||
listId: "list-456",
|
||||
},
|
||||
{
|
||||
boardId: "board-789",
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload.event).toBe("card.created");
|
||||
expect(payload.timestamp).toBe("2024-01-15T12:00:00.000Z");
|
||||
expect(payload.data.card).toEqual({
|
||||
id: "card-123",
|
||||
title: "Test Card",
|
||||
description: undefined,
|
||||
dueDate: null,
|
||||
listId: "list-456",
|
||||
boardId: "board-789",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes optional card fields when provided", () => {
|
||||
const dueDate = new Date("2024-02-01T10:00:00.000Z");
|
||||
const payload = createCardWebhookPayload(
|
||||
"card.updated",
|
||||
{
|
||||
id: "card-123",
|
||||
title: "Test Card",
|
||||
description: "A description",
|
||||
dueDate,
|
||||
listId: "list-456",
|
||||
},
|
||||
{
|
||||
boardId: "board-789",
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload.data.card.description).toBe("A description");
|
||||
expect(payload.data.card.dueDate).toBe("2024-02-01T10:00:00.000Z");
|
||||
});
|
||||
|
||||
it("includes board context when provided", () => {
|
||||
const payload = createCardWebhookPayload(
|
||||
"card.created",
|
||||
{
|
||||
id: "card-123",
|
||||
title: "Test Card",
|
||||
listId: "list-456",
|
||||
},
|
||||
{
|
||||
boardId: "board-789",
|
||||
boardName: "My Board",
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload.data.board).toEqual({
|
||||
id: "board-789",
|
||||
name: "My Board",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes list context when provided", () => {
|
||||
const payload = createCardWebhookPayload(
|
||||
"card.created",
|
||||
{
|
||||
id: "card-123",
|
||||
title: "Test Card",
|
||||
listId: "list-456",
|
||||
},
|
||||
{
|
||||
boardId: "board-789",
|
||||
listName: "To Do",
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload.data.list).toEqual({
|
||||
id: "list-456",
|
||||
name: "To Do",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes user context when provided", () => {
|
||||
const payload = createCardWebhookPayload(
|
||||
"card.created",
|
||||
{
|
||||
id: "card-123",
|
||||
title: "Test Card",
|
||||
listId: "list-456",
|
||||
},
|
||||
{
|
||||
boardId: "board-789",
|
||||
user: {
|
||||
id: "user-111",
|
||||
name: "John Doe",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload.data.user).toEqual({
|
||||
id: "user-111",
|
||||
name: "John Doe",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes changes for card.updated events", () => {
|
||||
const payload = createCardWebhookPayload(
|
||||
"card.updated",
|
||||
{
|
||||
id: "card-123",
|
||||
title: "Updated Title",
|
||||
listId: "list-456",
|
||||
},
|
||||
{
|
||||
boardId: "board-789",
|
||||
changes: {
|
||||
title: { from: "Old Title", to: "Updated Title" },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload.data.changes).toEqual({
|
||||
title: { from: "Old Title", to: "Updated Title" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendWebhookToUrl", () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const mockPayload: WebhookPayload = {
|
||||
event: "card.created",
|
||||
timestamp: "2024-01-15T12:00:00.000Z",
|
||||
data: {
|
||||
card: {
|
||||
id: "card-123",
|
||||
title: "Test Card",
|
||||
listId: "list-456",
|
||||
boardId: "board-789",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("SSRF protection", () => {
|
||||
it("blocks HTTP URLs", async () => {
|
||||
const result = await sendWebhookToUrl("http://example.com/webhook", undefined, mockPayload);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("HTTPS");
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks localhost", async () => {
|
||||
const result = await sendWebhookToUrl("https://localhost/webhook", undefined, mockPayload);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Localhost");
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks 127.0.0.1", async () => {
|
||||
const result = await sendWebhookToUrl("https://127.0.0.1/webhook", undefined, mockPayload);
|
||||
expect(result.success).toBe(false);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks cloud metadata endpoint", async () => {
|
||||
const result = await sendWebhookToUrl("https://169.254.169.254/latest/meta-data/", undefined, mockPayload);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("metadata");
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks private 10.x.x.x IPs", async () => {
|
||||
const result = await sendWebhookToUrl("https://10.0.0.1/webhook", undefined, mockPayload);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Private");
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks private 192.168.x.x IPs", async () => {
|
||||
const result = await sendWebhookToUrl("https://192.168.1.1/webhook", undefined, mockPayload);
|
||||
expect(result.success).toBe(false);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows valid HTTPS URLs", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
const result = await sendWebhookToUrl("https://example.com/webhook", undefined, mockPayload);
|
||||
expect(result.success).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("sends POST request with correct headers", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
await sendWebhookToUrl("https://example.com/webhook", undefined, mockPayload);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://example.com/webhook",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
"X-Webhook-Event": "card.created",
|
||||
"X-Webhook-Timestamp": "2024-01-15T12:00:00.000Z",
|
||||
}),
|
||||
body: JSON.stringify(mockPayload),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes signature header when secret is provided", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
await sendWebhookToUrl("https://example.com/webhook", "my-secret", mockPayload);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://example.com/webhook",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"X-Webhook-Signature": expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns success for 2xx responses", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const result = await sendWebhookToUrl(
|
||||
"https://example.com/webhook",
|
||||
undefined,
|
||||
mockPayload,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: true, statusCode: 200 });
|
||||
});
|
||||
|
||||
it("returns failure for non-2xx responses", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
});
|
||||
|
||||
const result = await sendWebhookToUrl(
|
||||
"https://example.com/webhook",
|
||||
undefined,
|
||||
mockPayload,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
statusCode: 500,
|
||||
error: "500 Internal Server Error",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns failure on network error", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new Error("Network error"),
|
||||
);
|
||||
|
||||
const result = await sendWebhookToUrl(
|
||||
"https://example.com/webhook",
|
||||
undefined,
|
||||
mockPayload,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "Network error",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns timeout error when request takes too long", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
const error = new Error("Aborted");
|
||||
error.name = "AbortError";
|
||||
reject(error);
|
||||
}, 15000);
|
||||
}),
|
||||
);
|
||||
|
||||
const resultPromise = sendWebhookToUrl(
|
||||
"https://example.com/webhook",
|
||||
undefined,
|
||||
mockPayload,
|
||||
);
|
||||
|
||||
// Advance timers to trigger the abort
|
||||
vi.advanceTimersByTime(15000);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "Request timed out",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendWebhooksForWorkspace", () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const mockDb = {} as Parameters<typeof sendWebhooksForWorkspace>[0];
|
||||
|
||||
const mockPayload: WebhookPayload = {
|
||||
event: "card.created",
|
||||
timestamp: "2024-01-15T12:00:00.000Z",
|
||||
data: {
|
||||
card: {
|
||||
id: "card-123",
|
||||
title: "Test Card",
|
||||
listId: "list-456",
|
||||
boardId: "board-789",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("sends to all active webhooks subscribed to the event", async () => {
|
||||
mockGetActiveByWorkspaceId.mockResolvedValueOnce([
|
||||
{
|
||||
id: 1,
|
||||
publicId: "wh-1",
|
||||
url: "https://example.com/webhook1",
|
||||
secret: "secret1",
|
||||
events: ["card.created", "card.updated"],
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
publicId: "wh-2",
|
||||
url: "https://example.com/webhook2",
|
||||
secret: null,
|
||||
events: ["card.created"],
|
||||
active: true,
|
||||
},
|
||||
]);
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
|
||||
|
||||
// Event filtering now happens at DB level
|
||||
expect(mockGetActiveByWorkspaceId).toHaveBeenCalledWith(
|
||||
mockDb,
|
||||
1,
|
||||
"card.created",
|
||||
);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://example.com/webhook1",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://example.com/webhook2",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not send when no webhooks match the event (DB-level filtering)", async () => {
|
||||
// DB-level event filter returns empty array when no webhooks match
|
||||
mockGetActiveByWorkspaceId.mockResolvedValueOnce([]);
|
||||
|
||||
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
|
||||
|
||||
expect(mockGetActiveByWorkspaceId).toHaveBeenCalledWith(
|
||||
mockDb,
|
||||
1,
|
||||
"card.created",
|
||||
);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("continues sending to other webhooks when one fails", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
mockGetActiveByWorkspaceId.mockResolvedValueOnce([
|
||||
{
|
||||
id: 1,
|
||||
publicId: "wh-1",
|
||||
url: "https://example.com/webhook1",
|
||||
secret: null,
|
||||
events: ["card.created"],
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
publicId: "wh-2",
|
||||
url: "https://example.com/webhook2",
|
||||
secret: null,
|
||||
events: ["card.created"],
|
||||
active: true,
|
||||
},
|
||||
]);
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Error" })
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Webhook delivery failed"),
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("handles empty webhook list", async () => {
|
||||
mockGetActiveByWorkspaceId.mockResolvedValueOnce([]);
|
||||
|
||||
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("catches and logs DB errors without throwing", async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
mockGetActiveByWorkspaceId.mockRejectedValueOnce(
|
||||
new Error("DB connection failed"),
|
||||
);
|
||||
|
||||
// Should not throw — the try/catch absorbs the error
|
||||
await expect(
|
||||
sendWebhooksForWorkspace(mockDb, 1, mockPayload),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Failed to send webhooks for workspace:",
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("webhookUrlSchema", () => {
|
||||
it("accepts valid HTTPS URLs", () => {
|
||||
expect(webhookUrlSchema.safeParse("https://example.com/webhook").success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects HTTP URLs", () => {
|
||||
const result = webhookUrlSchema.safeParse("http://example.com/webhook");
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects localhost", () => {
|
||||
const result = webhookUrlSchema.safeParse("https://localhost/webhook");
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects private IPs", () => {
|
||||
expect(webhookUrlSchema.safeParse("https://10.0.0.1/webhook").success).toBe(false);
|
||||
expect(webhookUrlSchema.safeParse("https://192.168.1.1/webhook").success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects cloud metadata endpoints", () => {
|
||||
expect(webhookUrlSchema.safeParse("https://169.254.169.254/latest").success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user