* 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>
243 lines
7.6 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|