refactor(api): extract webhook router support

This commit is contained in:
2026-03-31 13:41:02 +02:00
parent 5e74d61902
commit 67b24443d0
3 changed files with 245 additions and 105 deletions
@@ -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",
});
});
});
+139
View File
@@ -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<WebhookRecord | null>;
};
};
export function buildWebhookCreateData(
input: z.infer<typeof createWebhookInputSchema>,
) {
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<typeof updateWebhookInputSchema>,
) {
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<WebhookRecord, "id" | "name" | "secret">,
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<string, string> = {
"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<WebhookRecord, "id" | "name" | "url" | "secret">,
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);
}
}
+17 -105
View File
@@ -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<string, string> = {
"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,