diff --git a/packages/api/integration-tests/test-db.ts b/packages/api/integration-tests/test-db.ts new file mode 100644 index 00000000..9cecea75 --- /dev/null +++ b/packages/api/integration-tests/test-db.ts @@ -0,0 +1,75 @@ +import { PGlite } from "@electric-sql/pglite"; +import { uuid_ossp } from "@electric-sql/pglite/contrib/uuid_ossp"; +import { pg_trgm } from "@electric-sql/pglite/contrib/pg_trgm"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; +import type { Pool } from "pg"; + +import * as schema from "@kan/db/schema"; + +export type TestDbClient = NodePgDatabase & { + $client: Pool; +}; + +/** + * Creates a fresh in-memory PGlite database for testing. + * Each call returns an isolated database instance with migrations applied. + */ +export async function createTestDb(): Promise { + const client = new PGlite({ + extensions: { uuid_ossp, pg_trgm }, + }); + + const db = drizzle(client, { schema }); + + // Run migrations + await migrate(db, { migrationsFolder: "../../packages/db/migrations" }); + + return db as unknown as TestDbClient; +} + +/** + * Seeds a test database with a workspace and user for testing. + * Returns the created entities for use in tests. + */ +export async function seedTestData(db: TestDbClient) { + // Create a test user + const [user] = await db + .insert(schema.users) + .values({ + id: crypto.randomUUID(), + name: "Test User", + email: "test@example.com", + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + + // Create a test workspace (publicId must be exactly 12 chars) + const [workspace] = await db + .insert(schema.workspaces) + .values({ + publicId: "wstest123456", + name: "Test Workspace", + slug: "test-workspace", + ownerId: user!.id, + createdAt: new Date(), + }) + .returning(); + + // Add user as admin member of workspace + await db.insert(schema.workspaceMembers).values({ + publicId: "wm1234567890", + email: user!.email, + workspaceId: workspace!.id, + userId: user!.id, + createdBy: user!.id, + role: "admin", + status: "active", + createdAt: new Date(), + }); + + return { user: user!, workspace: workspace! }; +} diff --git a/packages/api/integration-tests/webhook.integration.test.ts b/packages/api/integration-tests/webhook.integration.test.ts new file mode 100644 index 00000000..28820417 --- /dev/null +++ b/packages/api/integration-tests/webhook.integration.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, beforeEach } from "vitest"; + +import * as webhookRepo from "@kan/db/repository/webhook.repo"; +import { createTestDb, seedTestData, type TestDbClient } from "./test-db"; + +describe("webhook repository integration tests", () => { + let db: TestDbClient; + let testUser: { id: string; name: string | null }; + let testWorkspace: { id: number; publicId: string }; + + beforeEach(async () => { + db = await createTestDb(); + const seeded = await seedTestData(db); + testUser = seeded.user; + testWorkspace = seeded.workspace; + }); + + describe("create", () => { + it("creates a webhook with all fields", async () => { + const webhook = await webhookRepo.create(db, { + workspaceId: testWorkspace.id, + name: "My Webhook", + url: "https://example.com/webhook", + secret: "my-secret", + events: ["card.created", "card.updated"], + createdBy: testUser.id, + }); + + expect(webhook).not.toBeNull(); + expect(webhook!.name).toBe("My Webhook"); + expect(webhook!.url).toBe("https://example.com/webhook"); + expect(webhook!.events).toEqual(["card.created", "card.updated"]); + expect(webhook!.active).toBe(true); + expect(webhook!.publicId).toMatch(/^[a-zA-Z0-9]{12}$/); + }); + + it("creates a webhook without secret", async () => { + const webhook = await webhookRepo.create(db, { + workspaceId: testWorkspace.id, + name: "No Secret Webhook", + url: "https://example.com/webhook", + events: ["card.deleted"], + createdBy: testUser.id, + }); + + expect(webhook).not.toBeNull(); + expect(webhook!.name).toBe("No Secret Webhook"); + }); + }); + + describe("getByPublicId", () => { + it("retrieves a webhook by public ID", async () => { + const created = await webhookRepo.create(db, { + workspaceId: testWorkspace.id, + name: "Test Webhook", + url: "https://example.com/webhook", + events: ["card.created"], + createdBy: testUser.id, + }); + + const retrieved = await webhookRepo.getByPublicId(db, created!.publicId); + + expect(retrieved).not.toBeNull(); + expect(retrieved!.publicId).toBe(created!.publicId); + expect(retrieved!.name).toBe("Test Webhook"); + }); + + it("returns null for non-existent public ID", async () => { + const retrieved = await webhookRepo.getByPublicId(db, "nonexistent12"); + + expect(retrieved).toBeNull(); + }); + }); + + describe("getAllByWorkspaceId", () => { + it("returns all webhooks for a workspace", async () => { + await webhookRepo.create(db, { + workspaceId: testWorkspace.id, + name: "Webhook 1", + url: "https://example.com/webhook1", + events: ["card.created"], + createdBy: testUser.id, + }); + + await webhookRepo.create(db, { + workspaceId: testWorkspace.id, + name: "Webhook 2", + url: "https://example.com/webhook2", + events: ["card.updated"], + createdBy: testUser.id, + }); + + const webhooks = await webhookRepo.getAllByWorkspaceId(db, testWorkspace.id); + + expect(webhooks).toHaveLength(2); + expect(webhooks.map((w) => w.name)).toContain("Webhook 1"); + expect(webhooks.map((w) => w.name)).toContain("Webhook 2"); + }); + + it("returns empty array for workspace with no webhooks", async () => { + const webhooks = await webhookRepo.getAllByWorkspaceId(db, testWorkspace.id); + + expect(webhooks).toEqual([]); + }); + }); + + describe("getActiveByWorkspaceId", () => { + it("returns only active webhooks", async () => { + const active = await webhookRepo.create(db, { + workspaceId: testWorkspace.id, + name: "Active Webhook", + url: "https://example.com/active", + events: ["card.created"], + createdBy: testUser.id, + }); + + const inactive = await webhookRepo.create(db, { + workspaceId: testWorkspace.id, + name: "Inactive Webhook", + url: "https://example.com/inactive", + events: ["card.created"], + createdBy: testUser.id, + }); + + // Deactivate one webhook + await webhookRepo.update(db, inactive!.publicId, { active: false }); + + const activeWebhooks = await webhookRepo.getActiveByWorkspaceId(db, testWorkspace.id); + + expect(activeWebhooks).toHaveLength(1); + // getActiveByWorkspaceId returns only publicId, url, secret, events + expect(activeWebhooks[0]!.url).toBe("https://example.com/active"); + expect(activeWebhooks[0]!.publicId).toBe(active!.publicId); + }); + }); + + describe("update", () => { + it("updates webhook name", async () => { + const created = await webhookRepo.create(db, { + workspaceId: testWorkspace.id, + name: "Original Name", + url: "https://example.com/webhook", + events: ["card.created"], + createdBy: testUser.id, + }); + + const updated = await webhookRepo.update(db, created!.publicId, { + name: "Updated Name", + }); + + expect(updated).not.toBeNull(); + expect(updated!.name).toBe("Updated Name"); + expect(updated!.url).toBe("https://example.com/webhook"); // Unchanged + }); + + it("updates webhook events", async () => { + const created = await webhookRepo.create(db, { + workspaceId: testWorkspace.id, + name: "Test Webhook", + url: "https://example.com/webhook", + events: ["card.created"], + createdBy: testUser.id, + }); + + const updated = await webhookRepo.update(db, created!.publicId, { + events: ["card.created", "card.updated", "card.deleted"], + }); + + expect(updated!.events).toEqual(["card.created", "card.updated", "card.deleted"]); + }); + + it("updates webhook active status", async () => { + const created = await webhookRepo.create(db, { + workspaceId: testWorkspace.id, + name: "Test Webhook", + url: "https://example.com/webhook", + events: ["card.created"], + createdBy: testUser.id, + }); + + expect(created!.active).toBe(true); + + const updated = await webhookRepo.update(db, created!.publicId, { + active: false, + }); + + expect(updated!.active).toBe(false); + }); + + it("sets updatedAt timestamp on update", async () => { + const created = await webhookRepo.create(db, { + workspaceId: testWorkspace.id, + name: "Test Webhook", + url: "https://example.com/webhook", + events: ["card.created"], + createdBy: testUser.id, + }); + + // create() doesn't return updatedAt, verify via getByPublicId + const initial = await webhookRepo.getByPublicId(db, created!.publicId); + expect(initial!.updatedAt).toBeNull(); + + const updated = await webhookRepo.update(db, created!.publicId, { + name: "Updated", + }); + + expect(updated!.updatedAt).not.toBeNull(); + expect(updated!.updatedAt).toBeInstanceOf(Date); + }); + + it("returns null for non-existent webhook", async () => { + const updated = await webhookRepo.update(db, "nonexistent12", { + name: "Updated", + }); + + expect(updated).toBeNull(); + }); + }); + + describe("hardDelete", () => { + it("deletes a webhook permanently", async () => { + const created = await webhookRepo.create(db, { + workspaceId: testWorkspace.id, + name: "To Be Deleted", + url: "https://example.com/webhook", + events: ["card.created"], + createdBy: testUser.id, + }); + + await webhookRepo.hardDelete(db, created!.publicId); + + const retrieved = await webhookRepo.getByPublicId(db, created!.publicId); + expect(retrieved).toBeNull(); + }); + + it("does not throw for non-existent webhook", async () => { + await expect( + webhookRepo.hardDelete(db, "nonexistent12"), + ).resolves.not.toThrow(); + }); + }); +}); diff --git a/packages/api/package.json b/packages/api/package.json index 30bdb7a1..b8caf407 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -40,6 +40,8 @@ "dev": "tsc", "format": "prettier --check . --ignore-path ../../.gitignore", "lint": "eslint", + "test": "vitest run", + "test:watch": "vitest", "typecheck": "tsc --noEmit --emitDeclarationOnly false" }, "dependencies": { @@ -60,7 +62,8 @@ "@kan/tsconfig": "workspace:*", "eslint": "catalog:", "prettier": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "^3.0.0" }, "prettier": "@kan/prettier-config" } diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index 21493c5b..711e3ca6 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -11,6 +11,7 @@ import { listRouter } from "./routers/list"; import { memberRouter } from "./routers/member"; import { permissionRouter } from "./routers/permission"; import { userRouter } from "./routers/user"; +import { webhookRouter } from "./routers/webhook"; import { workspaceRouter } from "./routers/workspace"; import { createTRPCRouter } from "./trpc"; @@ -27,6 +28,7 @@ export const appRouter = createTRPCRouter({ import: importRouter, permission: permissionRouter, user: userRouter, + webhook: webhookRouter, workspace: workspaceRouter, integration: integrationRouter, }); diff --git a/packages/api/src/routers/webhook.test.ts b/packages/api/src/routers/webhook.test.ts new file mode 100644 index 00000000..9086bd9f --- /dev/null +++ b/packages/api/src/routers/webhook.test.ts @@ -0,0 +1,423 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { TRPCError } from "@trpc/server"; + +vi.mock("@kan/db/repository/webhook.repo", () => ({ + getAllByWorkspaceId: vi.fn(), + getByPublicId: vi.fn(), + create: vi.fn(), + update: vi.fn(), + hardDelete: vi.fn(), +})); + +vi.mock("@kan/db/repository/workspace.repo", () => ({ + getByPublicId: vi.fn(), +})); + +vi.mock("../utils/permissions", () => ({ + assertPermission: vi.fn(), +})); + +import * as webhookRepo from "@kan/db/repository/webhook.repo"; +import * as workspaceRepo from "@kan/db/repository/workspace.repo"; +import { assertPermission } from "../utils/permissions"; + +const mockGetAllByWorkspaceId = webhookRepo.getAllByWorkspaceId as ReturnType; +const mockGetByPublicId = webhookRepo.getByPublicId as ReturnType; +const mockCreate = webhookRepo.create as ReturnType; +const mockUpdate = webhookRepo.update as ReturnType; +const mockHardDelete = webhookRepo.hardDelete as ReturnType; +const mockWorkspaceGetByPublicId = workspaceRepo.getByPublicId as ReturnType; +const mockAssertPermission = assertPermission as ReturnType; + +// We need to import the router after mocks are set up +// Testing approach: call the internal handler logic through a test wrapper +describe("webhook router", () => { + const mockDb = {} as never; + const mockUser = { id: "user-123", name: "Test User", email: "test@example.com" }; + const mockWorkspace = { id: 1, publicId: "ws-123456789" }; + const mockWebhook = { + id: 1, + publicId: "wh-123456789", + workspaceId: 1, + name: "My Webhook", + url: "https://example.com/webhook", + secret: "secret123", + events: ["card.created", "card.updated"] as const, + active: true, + createdAt: new Date("2024-01-15"), + updatedAt: null, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockAssertPermission.mockResolvedValue(undefined); + }); + + describe("authorization", () => { + it("throws UNAUTHORIZED when user is not authenticated", async () => { + // Import fresh to get mocked version + const { webhookRouter } = await import("./webhook"); + + const ctx = { + user: null, + db: mockDb, + } as never; + + await expect( + webhookRouter.createCaller(ctx).list({ workspacePublicId: "ws-123456789" }), + ).rejects.toThrow(TRPCError); + }); + + it("throws NOT_FOUND when workspace does not exist", async () => { + const { webhookRouter } = await import("./webhook"); + + mockWorkspaceGetByPublicId.mockResolvedValueOnce(null); + + const ctx = { + user: mockUser, + db: mockDb, + } as never; + + await expect( + webhookRouter.createCaller(ctx).list({ workspacePublicId: "ws-nonexistent" }), + ).rejects.toThrow(TRPCError); + }); + + it("checks workspace:manage permission via assertPermission", async () => { + const { webhookRouter } = await import("./webhook"); + + mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace); + mockGetAllByWorkspaceId.mockResolvedValueOnce([]); + + const ctx = { + user: mockUser, + db: mockDb, + } as never; + + await webhookRouter.createCaller(ctx).list({ workspacePublicId: "ws-123456789" }); + + expect(mockAssertPermission).toHaveBeenCalledWith( + mockDb, + mockUser.id, + mockWorkspace.id, + "workspace:manage", + ); + }); + }); + + describe("list", () => { + it("returns all webhooks for workspace", async () => { + const { webhookRouter } = await import("./webhook"); + + mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace); + mockGetAllByWorkspaceId.mockResolvedValueOnce([mockWebhook]); + + const ctx = { + user: mockUser, + db: mockDb, + } as never; + + const result = await webhookRouter.createCaller(ctx).list({ + workspacePublicId: "ws-123456789", + }); + + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe("My Webhook"); + expect(mockGetAllByWorkspaceId).toHaveBeenCalledWith(mockDb, mockWorkspace.id); + }); + + it("returns empty array when no webhooks exist", async () => { + const { webhookRouter } = await import("./webhook"); + + mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace); + mockGetAllByWorkspaceId.mockResolvedValueOnce([]); + + const ctx = { + user: mockUser, + db: mockDb, + } as never; + + const result = await webhookRouter.createCaller(ctx).list({ + workspacePublicId: "ws-123456789", + }); + + expect(result).toEqual([]); + }); + }); + + describe("create", () => { + it("creates a webhook with valid input", async () => { + const { webhookRouter } = await import("./webhook"); + + const newWebhook = { + publicId: "wh-new123456", + name: "New Webhook", + url: "https://example.com/new", + events: ["card.created"] as const, + active: true, + createdAt: new Date(), + }; + + mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace); + mockCreate.mockResolvedValueOnce(newWebhook); + + const ctx = { + user: mockUser, + db: mockDb, + } as never; + + const result = await webhookRouter.createCaller(ctx).create({ + workspacePublicId: "ws-123456789", + name: "New Webhook", + url: "https://example.com/new", + events: ["card.created"], + }); + + expect(result.name).toBe("New Webhook"); + expect(mockCreate).toHaveBeenCalledWith(mockDb, { + workspaceId: mockWorkspace.id, + name: "New Webhook", + url: "https://example.com/new", + secret: undefined, + events: ["card.created"], + createdBy: mockUser.id, + }); + }); + + it("creates a webhook with secret", async () => { + const { webhookRouter } = await import("./webhook"); + + const newWebhook = { + publicId: "wh-new123456", + name: "Secure Webhook", + url: "https://example.com/secure", + events: ["card.created"] as const, + active: true, + createdAt: new Date(), + }; + + mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace); + mockCreate.mockResolvedValueOnce(newWebhook); + + const ctx = { + user: mockUser, + db: mockDb, + } as never; + + await webhookRouter.createCaller(ctx).create({ + workspacePublicId: "ws-123456789", + name: "Secure Webhook", + url: "https://example.com/secure", + secret: "my-secret-key", + events: ["card.created"], + }); + + expect(mockCreate).toHaveBeenCalledWith(mockDb, expect.objectContaining({ + secret: "my-secret-key", + })); + }); + + it("throws INTERNAL_SERVER_ERROR when create fails", async () => { + const { webhookRouter } = await import("./webhook"); + + mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace); + mockCreate.mockResolvedValueOnce(null); + + const ctx = { + user: mockUser, + db: mockDb, + } as never; + + await expect( + webhookRouter.createCaller(ctx).create({ + workspacePublicId: "ws-123456789", + name: "New Webhook", + url: "https://example.com/new", + events: ["card.created"], + }), + ).rejects.toThrow(TRPCError); + }); + }); + + describe("update", () => { + it("updates webhook name", async () => { + const { webhookRouter } = await import("./webhook"); + + const updatedWebhook = { ...mockWebhook, name: "Updated Name" }; + + mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace); + mockGetByPublicId.mockResolvedValueOnce(mockWebhook); + mockUpdate.mockResolvedValueOnce(updatedWebhook); + + const ctx = { + user: mockUser, + db: mockDb, + } as never; + + const result = await webhookRouter.createCaller(ctx).update({ + workspacePublicId: "ws-123456789", + webhookPublicId: "wh-123456789", + name: "Updated Name", + }); + + expect(result.name).toBe("Updated Name"); + expect(mockUpdate).toHaveBeenCalledWith(mockDb, "wh-123456789", { + name: "Updated Name", + url: undefined, + secret: undefined, + events: undefined, + active: undefined, + }); + }); + + it("throws NOT_FOUND when webhook does not exist", async () => { + const { webhookRouter } = await import("./webhook"); + + mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace); + mockGetByPublicId.mockResolvedValueOnce(null); + + const ctx = { + user: mockUser, + db: mockDb, + } as never; + + await expect( + webhookRouter.createCaller(ctx).update({ + workspacePublicId: "ws-123456789", + webhookPublicId: "wh-nonexistent", + name: "Updated Name", + }), + ).rejects.toThrow(TRPCError); + }); + + it("throws NOT_FOUND when webhook belongs to different workspace", async () => { + const { webhookRouter } = await import("./webhook"); + + const webhookFromDifferentWorkspace = { ...mockWebhook, workspaceId: 999 }; + + mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace); + mockGetByPublicId.mockResolvedValueOnce(webhookFromDifferentWorkspace); + + const ctx = { + user: mockUser, + db: mockDb, + } as never; + + await expect( + webhookRouter.createCaller(ctx).update({ + workspacePublicId: "ws-123456789", + webhookPublicId: "wh-123456789", + name: "Updated Name", + }), + ).rejects.toThrow(TRPCError); + }); + }); + + describe("delete", () => { + it("deletes webhook successfully", async () => { + const { webhookRouter } = await import("./webhook"); + + mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace); + mockGetByPublicId.mockResolvedValueOnce(mockWebhook); + mockHardDelete.mockResolvedValueOnce(undefined); + + const ctx = { + user: mockUser, + db: mockDb, + } as never; + + const result = await webhookRouter.createCaller(ctx).delete({ + workspacePublicId: "ws-123456789", + webhookPublicId: "wh-123456789", + }); + + expect(result).toEqual({ success: true }); + expect(mockHardDelete).toHaveBeenCalledWith(mockDb, "wh-123456789"); + }); + + it("throws NOT_FOUND when webhook does not exist", async () => { + const { webhookRouter } = await import("./webhook"); + + mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace); + mockGetByPublicId.mockResolvedValueOnce(null); + + const ctx = { + user: mockUser, + db: mockDb, + } as never; + + await expect( + webhookRouter.createCaller(ctx).delete({ + workspacePublicId: "ws-123456789", + webhookPublicId: "wh-nonexistent", + }), + ).rejects.toThrow(TRPCError); + }); + }); + + describe("test", () => { + beforeEach(() => { + global.fetch = vi.fn(); + }); + + it("sends test payload to webhook URL", async () => { + const { webhookRouter } = await import("./webhook"); + + mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace); + mockGetByPublicId.mockResolvedValueOnce(mockWebhook); + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const ctx = { + user: mockUser, + db: mockDb, + } as never; + + const result = await webhookRouter.createCaller(ctx).test({ + workspacePublicId: "ws-123456789", + webhookPublicId: "wh-123456789", + }); + + expect(result.success).toBe(true); + expect(result.statusCode).toBe(200); + expect(global.fetch).toHaveBeenCalledWith( + mockWebhook.url, + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "Content-Type": "application/json", + "X-Webhook-Event": "card.created", + }), + }), + ); + }); + + it("returns error when test fails", async () => { + const { webhookRouter } = await import("./webhook"); + + mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace); + mockGetByPublicId.mockResolvedValueOnce(mockWebhook); + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }); + + const ctx = { + user: mockUser, + db: mockDb, + } as never; + + const result = await webhookRouter.createCaller(ctx).test({ + workspacePublicId: "ws-123456789", + webhookPublicId: "wh-123456789", + }); + + expect(result.success).toBe(false); + expect(result.statusCode).toBe(500); + expect(result.error).toContain("500"); + }); + }); +}); diff --git a/packages/api/src/routers/webhook.ts b/packages/api/src/routers/webhook.ts new file mode 100644 index 00000000..46f25737 --- /dev/null +++ b/packages/api/src/routers/webhook.ts @@ -0,0 +1,359 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +import * as webhookRepo from "@kan/db/repository/webhook.repo"; +import * as workspaceRepo from "@kan/db/repository/workspace.repo"; +import { webhookEvents } from "@kan/db/schema"; + +import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { assertPermission } from "../utils/permissions"; +import { + webhookUrlSchema, + sendWebhookToUrl, + createCardWebhookPayload, +} from "../utils/webhook"; + +const webhookEventSchema = z.enum(webhookEvents); + +export const webhookRouter = createTRPCRouter({ + list: protectedProcedure + .meta({ + openapi: { + summary: "Get all webhooks for a workspace", + method: "GET", + path: "/workspaces/{workspacePublicId}/webhooks", + description: "Retrieves all webhooks configured for a workspace", + tags: ["Webhooks"], + protect: true, + }, + }) + .input(z.object({ workspacePublicId: z.string().min(12) })) + .output( + z.array( + z.object({ + publicId: z.string(), + name: z.string(), + url: z.string(), + events: z.array(webhookEventSchema), + active: z.boolean(), + createdAt: z.date(), + updatedAt: z.date().nullable(), + }), + ), + ) + .query(async ({ ctx, input }) => { + const userId = ctx.user?.id; + + if (!userId) + throw new TRPCError({ + message: "User not authenticated", + code: "UNAUTHORIZED", + }); + + const workspace = await workspaceRepo.getByPublicId( + ctx.db, + input.workspacePublicId, + ); + + if (!workspace) + throw new TRPCError({ + message: "Workspace not found", + code: "NOT_FOUND", + }); + + await assertPermission(ctx.db, userId, workspace.id, "workspace:manage"); + + return webhookRepo.getAllByWorkspaceId(ctx.db, workspace.id); + }), + + create: protectedProcedure + .meta({ + openapi: { + summary: "Create a webhook", + method: "POST", + path: "/workspaces/{workspacePublicId}/webhooks", + description: "Creates a new webhook for a workspace", + tags: ["Webhooks"], + protect: true, + }, + }) + .input( + z.object({ + workspacePublicId: z.string().min(12), + name: z.string().min(1).max(255), + url: webhookUrlSchema, + secret: z.string().max(512).optional(), + events: z.array(webhookEventSchema).min(1), + }), + ) + .output( + z.object({ + publicId: z.string(), + name: z.string(), + url: z.string(), + events: z.array(webhookEventSchema), + active: z.boolean(), + createdAt: z.date(), + }), + ) + .mutation(async ({ ctx, input }) => { + const userId = ctx.user?.id; + + if (!userId) + throw new TRPCError({ + message: "User not authenticated", + code: "UNAUTHORIZED", + }); + + const workspace = await workspaceRepo.getByPublicId( + ctx.db, + input.workspacePublicId, + ); + + if (!workspace) + throw new TRPCError({ + message: "Workspace not found", + code: "NOT_FOUND", + }); + + await assertPermission(ctx.db, userId, workspace.id, "workspace:manage"); + + const result = await webhookRepo.create(ctx.db, { + workspaceId: workspace.id, + name: input.name, + url: input.url, + secret: input.secret, + events: input.events, + createdBy: userId, + }); + + if (!result) + throw new TRPCError({ + message: "Unable to create webhook", + code: "INTERNAL_SERVER_ERROR", + }); + + return result; + }), + + update: protectedProcedure + .meta({ + openapi: { + summary: "Update a webhook", + method: "PUT", + path: "/workspaces/{workspacePublicId}/webhooks/{webhookPublicId}", + description: "Updates a webhook by its public ID", + tags: ["Webhooks"], + protect: true, + }, + }) + .input( + z.object({ + workspacePublicId: z.string().min(12), + webhookPublicId: z.string().min(12), + name: z.string().min(1).max(255).optional(), + url: webhookUrlSchema.optional(), + secret: z.string().max(512).optional(), + events: z.array(webhookEventSchema).min(1).optional(), + active: z.boolean().optional(), + }), + ) + .output( + z.object({ + publicId: z.string(), + name: z.string(), + url: z.string(), + events: z.array(webhookEventSchema), + active: z.boolean(), + createdAt: z.date(), + updatedAt: z.date().nullable(), + }), + ) + .mutation(async ({ ctx, input }) => { + const userId = ctx.user?.id; + + if (!userId) + throw new TRPCError({ + message: "User not authenticated", + code: "UNAUTHORIZED", + }); + + const workspace = await workspaceRepo.getByPublicId( + ctx.db, + input.workspacePublicId, + ); + + if (!workspace) + throw new TRPCError({ + message: "Workspace not found", + code: "NOT_FOUND", + }); + + await assertPermission(ctx.db, userId, workspace.id, "workspace:manage"); + + const webhook = await webhookRepo.getByPublicId( + ctx.db, + input.webhookPublicId, + ); + + if (!webhook || webhook.workspaceId !== workspace.id) + throw new TRPCError({ + message: "Webhook not found", + code: "NOT_FOUND", + }); + + const result = await webhookRepo.update(ctx.db, input.webhookPublicId, { + name: input.name, + url: input.url, + secret: input.secret, + events: input.events, + active: input.active, + }); + + if (!result) + throw new TRPCError({ + message: "Unable to update webhook", + code: "INTERNAL_SERVER_ERROR", + }); + + return result; + }), + + delete: protectedProcedure + .meta({ + openapi: { + summary: "Delete a webhook", + method: "DELETE", + path: "/workspaces/{workspacePublicId}/webhooks/{webhookPublicId}", + description: "Deletes a webhook by its public ID", + tags: ["Webhooks"], + protect: true, + }, + }) + .input( + z.object({ + workspacePublicId: z.string().min(12), + webhookPublicId: z.string().min(12), + }), + ) + .output(z.object({ success: z.boolean() })) + .mutation(async ({ ctx, input }) => { + const userId = ctx.user?.id; + + if (!userId) + throw new TRPCError({ + message: "User not authenticated", + code: "UNAUTHORIZED", + }); + + const workspace = await workspaceRepo.getByPublicId( + ctx.db, + input.workspacePublicId, + ); + + if (!workspace) + throw new TRPCError({ + message: "Workspace not found", + code: "NOT_FOUND", + }); + + await assertPermission(ctx.db, userId, workspace.id, "workspace:manage"); + + const webhook = await webhookRepo.getByPublicId( + ctx.db, + input.webhookPublicId, + ); + + if (!webhook || webhook.workspaceId !== workspace.id) + throw new TRPCError({ + message: "Webhook not found", + code: "NOT_FOUND", + }); + + await webhookRepo.hardDelete(ctx.db, input.webhookPublicId); + + return { success: true }; + }), + + test: protectedProcedure + .meta({ + openapi: { + summary: "Test a webhook", + method: "POST", + path: "/workspaces/{workspacePublicId}/webhooks/{webhookPublicId}/test", + description: "Sends a test payload to a webhook", + tags: ["Webhooks"], + protect: true, + }, + }) + .input( + z.object({ + workspacePublicId: z.string().min(12), + webhookPublicId: z.string().min(12), + }), + ) + .output( + z.object({ + success: z.boolean(), + statusCode: z.number().optional(), + error: z.string().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const userId = ctx.user?.id; + + if (!userId) + throw new TRPCError({ + message: "User not authenticated", + code: "UNAUTHORIZED", + }); + + const workspace = await workspaceRepo.getByPublicId( + ctx.db, + input.workspacePublicId, + ); + + if (!workspace) + throw new TRPCError({ + message: "Workspace not found", + code: "NOT_FOUND", + }); + + await assertPermission(ctx.db, userId, workspace.id, "workspace:manage"); + + const webhook = await webhookRepo.getByPublicId( + ctx.db, + input.webhookPublicId, + ); + + if (!webhook || webhook.workspaceId !== workspace.id) + throw new TRPCError({ + message: "Webhook not found", + code: "NOT_FOUND", + }); + + const testPayload = createCardWebhookPayload("card.created", { + id: "test-card-id", + title: "Test Card", + description: "This is a test webhook payload", + dueDate: null, + listId: "test-list-id", + }, { + boardId: "test-board-id", + boardName: "Test Board", + listName: "Test List", + user: { + id: userId, + name: ctx.user?.name ?? "Test User", + }, + }); + + const result = await sendWebhookToUrl( + webhook.url, + webhook.secret ?? undefined, + testPayload, + ); + + return result; + }), +}); diff --git a/packages/api/src/utils/webhook.test.ts b/packages/api/src/utils/webhook.test.ts index 155b6c28..55574a1b 100644 --- a/packages/api/src/utils/webhook.test.ts +++ b/packages/api/src/utils/webhook.test.ts @@ -404,11 +404,10 @@ describe("webhook utilities", () => { await sendWebhooksForWorkspace(mockDb, 1, mockPayload); - // Event filtering now happens at DB level + // getActiveByWorkspaceId fetches all active webhooks; event filtering is client-side expect(mockGetActiveByWorkspaceId).toHaveBeenCalledWith( mockDb, 1, - "card.created", ); expect(global.fetch).toHaveBeenCalledTimes(2); expect(global.fetch).toHaveBeenCalledWith( @@ -421,8 +420,8 @@ describe("webhook utilities", () => { ); }); - 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 + 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); @@ -430,7 +429,6 @@ describe("webhook utilities", () => { expect(mockGetActiveByWorkspaceId).toHaveBeenCalledWith( mockDb, 1, - "card.created", ); expect(global.fetch).not.toHaveBeenCalled(); }); diff --git a/packages/api/src/utils/webhook.ts b/packages/api/src/utils/webhook.ts index f1fb2f37..a3583a2f 100644 --- a/packages/api/src/utils/webhook.ts +++ b/packages/api/src/utils/webhook.ts @@ -185,12 +185,13 @@ export async function sendWebhooksForWorkspace( payload: WebhookPayload, ): Promise { try { - // Get active webhooks for this workspace - const webhooks = await webhookRepo.getActiveByWorkspaceId(db, workspaceId); - - // Filter webhooks that are subscribed to this specific event - const webhooksForEvent = webhooks.filter((webhook) => - webhook.events.includes(payload.event), + // Get active webhooks for this workspace and filter by event client-side + const allWebhooks = await webhookRepo.getActiveByWorkspaceId( + db, + workspaceId, + ); + const webhooks = allWebhooks.filter((w) => + w.events.includes(payload.event), ); // Send to all subscribed webhooks in parallel (fire and forget) diff --git a/packages/api/vitest.config.ts b/packages/api/vitest.config.ts new file mode 100644 index 00000000..33dd7db1 --- /dev/null +++ b/packages/api/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; +import { resolve } from "path"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts", "integration-tests/**/*.test.ts"], + }, + resolve: { + alias: { + "@kan/db": resolve(__dirname, "../db/src"), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92b37baa..764bf98f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -351,6 +351,9 @@ importers: typescript: specifier: 'catalog:' version: 5.9.2 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1) packages/auth: dependencies: @@ -12681,6 +12684,14 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@20.19.11)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.1) + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1) + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 @@ -17944,6 +17955,27 @@ snapshots: - tsx - yaml + vite-node@3.2.4(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.1(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite@7.3.1(@types/node@20.19.11)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.1): dependencies: esbuild: 0.27.2 @@ -17959,6 +17991,21 @@ snapshots: terser: 5.44.1 yaml: 2.8.1 + vite@7.3.1(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.56.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.0.0 + fsevents: 2.3.3 + jiti: 2.4.2 + terser: 5.44.1 + yaml: 2.8.1 + vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.11)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.1): dependencies: '@types/chai': 5.2.3 @@ -18001,6 +18048,48 @@ snapshots: - tsx - yaml + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 25.0.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + w3c-keyname@2.2.8: {} watchpack@2.4.4: