Files
kan/packages/api/integration-tests/webhook.integration.test.ts
Nick Meinhold 1d5e3a936c feat(api): add webhook CRUD API router and tests (#393)
* 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>
2026-03-10 22:27:52 +00:00

243 lines
7.6 KiB
TypeScript

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();
});
});
});