From 67b24443d01b0dedcf2e8212fb5b4f4b7ad7e5cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 13:41:02 +0200 Subject: [PATCH] refactor(api): extract webhook router support --- .../api/src/__tests__/webhook-support.test.ts | 89 +++++++++++ packages/api/src/router/webhook-support.ts | 139 ++++++++++++++++++ packages/api/src/router/webhook.ts | 122 +++------------ 3 files changed, 245 insertions(+), 105 deletions(-) create mode 100644 packages/api/src/__tests__/webhook-support.test.ts create mode 100644 packages/api/src/router/webhook-support.ts diff --git a/packages/api/src/__tests__/webhook-support.test.ts b/packages/api/src/__tests__/webhook-support.test.ts new file mode 100644 index 0000000..3e00877 --- /dev/null +++ b/packages/api/src/__tests__/webhook-support.test.ts @@ -0,0 +1,89 @@ +import { createHmac } from "node:crypto"; +import { TRPCError } from "@trpc/server"; +import { describe, expect, it, vi } from "vitest"; +import { + buildWebhookCreateData, + buildWebhookTestRequest, + buildWebhookUpdateData, + loadWebhookOrThrow, + sendWebhookTestRequest, +} from "../router/webhook-support.js"; + +describe("webhook support", () => { + it("builds create and sparse update payloads", () => { + expect(buildWebhookCreateData({ + name: "Primary", + url: "https://example.com/webhook", + secret: "secret", + events: ["project.created"], + isActive: true, + })).toEqual({ + name: "Primary", + url: "https://example.com/webhook", + secret: "secret", + events: ["project.created"], + isActive: true, + }); + + expect(buildWebhookUpdateData({ + isActive: false, + secret: null, + })).toEqual({ + isActive: false, + secret: null, + }); + }); + + it("loads a webhook or throws", async () => { + const db = { + webhook: { + findUnique: vi.fn().mockResolvedValue(null), + }, + } as never; + + await expect(loadWebhookOrThrow(db, "missing")).rejects.toBeInstanceOf(TRPCError); + }); + + it("builds a signed test request when a secret exists", async () => { + const timestamp = "2026-03-31T12:00:00.000Z"; + const request = await buildWebhookTestRequest({ + id: "wh_1", + name: "Primary", + secret: "topsecret", + }, timestamp); + + const expectedBody = JSON.stringify({ + event: "webhook.test", + timestamp, + payload: { + webhookId: "wh_1", + webhookName: "Primary", + message: "This is a test payload from CapaKraken.", + }, + }); + + expect(request.body).toBe(expectedBody); + expect(request.headers["X-Webhook-Signature"]).toBe( + createHmac("sha256", "topsecret").update(expectedBody).digest("hex"), + ); + }); + + it("reports fetch failures as unsuccessful test results", async () => { + const fetchImpl = vi.fn().mockRejectedValue(new Error("network down")); + + await expect(sendWebhookTestRequest({ + id: "wh_1", + name: "Primary", + url: "https://example.com/webhook", + secret: null, + }, { + fetchImpl, + timestamp: "2026-03-31T12:00:00.000Z", + timeoutMs: 10, + })).resolves.toEqual({ + success: false, + statusCode: 0, + statusText: "network down", + }); + }); +}); diff --git a/packages/api/src/router/webhook-support.ts b/packages/api/src/router/webhook-support.ts new file mode 100644 index 0000000..cb890da --- /dev/null +++ b/packages/api/src/router/webhook-support.ts @@ -0,0 +1,139 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { WEBHOOK_EVENTS } from "../lib/webhook-dispatcher.js"; + +export const webhookEventEnum = z.enum(WEBHOOK_EVENTS as unknown as [string, ...string[]]); + +export const createWebhookInputSchema = z.object({ + name: z.string().min(1).max(200), + url: z.string().url(), + secret: z.string().optional(), + events: z.array(webhookEventEnum).min(1), + isActive: z.boolean().default(true), +}); + +export const updateWebhookInputSchema = z.object({ + name: z.string().min(1).max(200).optional(), + url: z.string().url().optional(), + secret: z.string().nullish(), + events: z.array(webhookEventEnum).min(1).optional(), + isActive: z.boolean().optional(), +}); + +type WebhookRecord = { + id: string; + name: string; + url: string; + secret?: string | null; + events?: string[]; + isActive?: boolean; +}; + +type WebhookDb = { + webhook: { + findUnique: (args: { where: { id: string } }) => Promise; + }; +}; + +export function buildWebhookCreateData( + input: z.infer, +) { + return { + name: input.name, + url: input.url, + ...(input.secret !== undefined ? { secret: input.secret } : {}), + events: input.events, + isActive: input.isActive, + }; +} + +export function buildWebhookUpdateData( + input: z.infer, +) { + return { + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.url !== undefined ? { url: input.url } : {}), + ...(input.secret !== undefined ? { secret: input.secret } : {}), + ...(input.events !== undefined ? { events: input.events } : {}), + ...(input.isActive !== undefined ? { isActive: input.isActive } : {}), + }; +} + +export async function loadWebhookOrThrow( + db: WebhookDb, + id: string, +) { + const webhook = await db.webhook.findUnique({ where: { id } }); + if (!webhook) { + throw new TRPCError({ code: "NOT_FOUND", message: "Webhook not found" }); + } + return webhook; +} + +export async function buildWebhookTestRequest( + webhook: Pick, + timestamp = new Date().toISOString(), +) { + const payload = { + event: "webhook.test", + timestamp, + payload: { + webhookId: webhook.id, + webhookName: webhook.name, + message: "This is a test payload from CapaKraken.", + }, + }; + + const body = JSON.stringify(payload); + const headers: Record = { + "Content-Type": "application/json", + "X-Webhook-Event": "webhook.test", + }; + + if (webhook.secret) { + const { createHmac } = await import("node:crypto"); + headers["X-Webhook-Signature"] = createHmac("sha256", webhook.secret) + .update(body) + .digest("hex"); + } + + return { body, headers }; +} + +export async function sendWebhookTestRequest( + webhook: Pick, + options: { + fetchImpl?: typeof fetch; + timeoutMs?: number; + timestamp?: string; + } = {}, +): Promise<{ success: boolean; statusCode: number; statusText: string }> { + const fetchImpl = options.fetchImpl ?? fetch; + const timeoutMs = options.timeoutMs ?? 5_000; + const { body, headers } = await buildWebhookTestRequest(webhook, options.timestamp); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetchImpl(webhook.url, { + method: "POST", + headers, + body, + signal: controller.signal, + }); + return { + success: response.ok, + statusCode: response.status, + statusText: response.statusText, + }; + } catch (error) { + return { + success: false, + statusCode: 0, + statusText: error instanceof Error ? error.message : "Unknown error", + }; + } finally { + clearTimeout(timeout); + } +} diff --git a/packages/api/src/router/webhook.ts b/packages/api/src/router/webhook.ts index 571e44e..1121f64 100644 --- a/packages/api/src/router/webhook.ts +++ b/packages/api/src/router/webhook.ts @@ -1,10 +1,14 @@ import { z } from "zod"; -import { TRPCError } from "@trpc/server"; import { createTRPCRouter, adminProcedure } from "../trpc.js"; -import { WEBHOOK_EVENTS } from "../lib/webhook-dispatcher.js"; import { createAuditEntry } from "../lib/audit.js"; - -const webhookEventEnum = z.enum(WEBHOOK_EVENTS as unknown as [string, ...string[]]); +import { + buildWebhookCreateData, + buildWebhookUpdateData, + createWebhookInputSchema, + loadWebhookOrThrow, + sendWebhookTestRequest, + updateWebhookInputSchema, +} from "./webhook-support.js"; export const webhookRouter = createTRPCRouter({ /** List all webhooks. */ @@ -17,34 +21,14 @@ export const webhookRouter = createTRPCRouter({ /** Get a single webhook by ID. */ getById: adminProcedure .input(z.object({ id: z.string() })) - .query(async ({ ctx, input }) => { - const wh = await ctx.db.webhook.findUnique({ where: { id: input.id } }); - if (!wh) { - throw new TRPCError({ code: "NOT_FOUND", message: "Webhook not found" }); - } - return wh; - }), + .query(async ({ ctx, input }) => loadWebhookOrThrow(ctx.db, input.id)), /** Create a new webhook. */ create: adminProcedure - .input( - z.object({ - name: z.string().min(1).max(200), - url: z.string().url(), - secret: z.string().optional(), - events: z.array(webhookEventEnum).min(1), - isActive: z.boolean().default(true), - }), - ) + .input(createWebhookInputSchema) .mutation(async ({ ctx, input }) => { const webhook = await ctx.db.webhook.create({ - data: { - name: input.name, - url: input.url, - ...(input.secret !== undefined ? { secret: input.secret } : {}), - events: input.events, - isActive: input.isActive, - }, + data: buildWebhookCreateData(input), }); void createAuditEntry({ @@ -66,30 +50,15 @@ export const webhookRouter = createTRPCRouter({ .input( z.object({ id: z.string(), - data: z.object({ - name: z.string().min(1).max(200).optional(), - url: z.string().url().optional(), - secret: z.string().nullish(), - events: z.array(webhookEventEnum).min(1).optional(), - isActive: z.boolean().optional(), - }), + data: updateWebhookInputSchema, }), ) .mutation(async ({ ctx, input }) => { - const existing = await ctx.db.webhook.findUnique({ where: { id: input.id } }); - if (!existing) { - throw new TRPCError({ code: "NOT_FOUND", message: "Webhook not found" }); - } + const existing = await loadWebhookOrThrow(ctx.db, input.id); const updated = await ctx.db.webhook.update({ where: { id: input.id }, - data: { - ...(input.data.name !== undefined ? { name: input.data.name } : {}), - ...(input.data.url !== undefined ? { url: input.data.url } : {}), - ...(input.data.secret !== undefined ? { secret: input.data.secret } : {}), - ...(input.data.events !== undefined ? { events: input.data.events } : {}), - ...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}), - }, + data: buildWebhookUpdateData(input.data), }); void createAuditEntry({ @@ -111,10 +80,7 @@ export const webhookRouter = createTRPCRouter({ delete: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { - const existing = await ctx.db.webhook.findUnique({ where: { id: input.id } }); - if (!existing) { - throw new TRPCError({ code: "NOT_FOUND", message: "Webhook not found" }); - } + const existing = await loadWebhookOrThrow(ctx.db, input.id); await ctx.db.webhook.delete({ where: { id: input.id } }); void createAuditEntry({ @@ -133,62 +99,8 @@ export const webhookRouter = createTRPCRouter({ test: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { - const wh = await ctx.db.webhook.findUnique({ where: { id: input.id } }); - if (!wh) { - throw new TRPCError({ code: "NOT_FOUND", message: "Webhook not found" }); - } - - const testPayload = { - event: "webhook.test", - timestamp: new Date().toISOString(), - payload: { - webhookId: wh.id, - webhookName: wh.name, - message: "This is a test payload from CapaKraken.", - }, - }; - - const body = JSON.stringify(testPayload); - - const headers: Record = { - "Content-Type": "application/json", - "X-Webhook-Event": "webhook.test", - }; - - if (wh.secret) { - const { createHmac } = await import("node:crypto"); - const signature = createHmac("sha256", wh.secret) - .update(body) - .digest("hex"); - headers["X-Webhook-Signature"] = signature; - } - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5_000); - - let result: { success: boolean; statusCode: number; statusText: string }; - - try { - const response = await fetch(wh.url, { - method: "POST", - headers, - body, - signal: controller.signal, - }); - result = { - success: response.ok, - statusCode: response.status, - statusText: response.statusText, - }; - } catch (err) { - result = { - success: false, - statusCode: 0, - statusText: err instanceof Error ? err.message : "Unknown error", - }; - } finally { - clearTimeout(timeout); - } + const wh = await loadWebhookOrThrow(ctx.db, input.id); + const result = await sendWebhookTestRequest(wh); void createAuditEntry({ db: ctx.db,