* feat(api): add webhook CRUD API router and tests Add tRPC router for managing workspace webhooks: - list, create, update, delete endpoints (admin role required) - test endpoint to send a synthetic payload to a webhook URL - URL validation, event subscription filtering - Unit tests for all router procedures - Integration tests with PGlite test database - Add vitest config and test infrastructure for API package Depends on #391 (DB schema & repository). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(api): use assertPermission instead of assertUserInWorkspace Replace assertUserInWorkspace with assertPermission("workspace:manage") per project conventions. The permissions system is the preferred authorization approach for new code. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(api): use @kan/db alias instead of relative imports in tests Replace relative path imports (../../db/src/...) with the @kan/db alias configured in vitest.config.ts for consistency and robustness. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(api): use webhookUrlSchema in router input validation Cherry-pick router-related changes from b2cc9ac: - Use extracted webhookUrlSchema zod validator in create/update input schemas for consistent SSRF checks Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(api): replace dynamic import with static import for webhook utility Add packages/api/src/utils/webhook.ts with sendWebhookToUrl, createCardWebhookPayload, and webhookUrlSchema. Replace the dynamic import() in the test endpoint with a static import at the top of the file for better tree-shaking, type-checking, and readability. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(api): align sendWebhooksForWorkspace tests with merged PR #392 The merged delivery utility uses client-side event filtering (getActiveByWorkspaceId takes 2 args, not 3). Update test assertions to match the actual implementation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Henry <30578846+hjball@users.noreply.github.com>
527 lines
15 KiB
TypeScript
527 lines
15 KiB
TypeScript
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);
|
|
|
|
// getActiveByWorkspaceId fetches all active webhooks; event filtering is client-side
|
|
expect(mockGetActiveByWorkspaceId).toHaveBeenCalledWith(
|
|
mockDb,
|
|
1,
|
|
);
|
|
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 (client-side filtering)", async () => {
|
|
// Returns webhooks that don't match the event — client-side filter excludes them
|
|
mockGetActiveByWorkspaceId.mockResolvedValueOnce([]);
|
|
|
|
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
|
|
|
|
expect(mockGetActiveByWorkspaceId).toHaveBeenCalledWith(
|
|
mockDb,
|
|
1,
|
|
);
|
|
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);
|
|
});
|
|
});
|
|
});
|