refactor(api): extract webhook router support
This commit is contained in:
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TRPCError } from "@trpc/server";
|
|
||||||
import { createTRPCRouter, adminProcedure } from "../trpc.js";
|
import { createTRPCRouter, adminProcedure } from "../trpc.js";
|
||||||
import { WEBHOOK_EVENTS } from "../lib/webhook-dispatcher.js";
|
|
||||||
import { createAuditEntry } from "../lib/audit.js";
|
import { createAuditEntry } from "../lib/audit.js";
|
||||||
|
import {
|
||||||
const webhookEventEnum = z.enum(WEBHOOK_EVENTS as unknown as [string, ...string[]]);
|
buildWebhookCreateData,
|
||||||
|
buildWebhookUpdateData,
|
||||||
|
createWebhookInputSchema,
|
||||||
|
loadWebhookOrThrow,
|
||||||
|
sendWebhookTestRequest,
|
||||||
|
updateWebhookInputSchema,
|
||||||
|
} from "./webhook-support.js";
|
||||||
|
|
||||||
export const webhookRouter = createTRPCRouter({
|
export const webhookRouter = createTRPCRouter({
|
||||||
/** List all webhooks. */
|
/** List all webhooks. */
|
||||||
@@ -17,34 +21,14 @@ export const webhookRouter = createTRPCRouter({
|
|||||||
/** Get a single webhook by ID. */
|
/** Get a single webhook by ID. */
|
||||||
getById: adminProcedure
|
getById: adminProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => loadWebhookOrThrow(ctx.db, input.id)),
|
||||||
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;
|
|
||||||
}),
|
|
||||||
|
|
||||||
/** Create a new webhook. */
|
/** Create a new webhook. */
|
||||||
create: adminProcedure
|
create: adminProcedure
|
||||||
.input(
|
.input(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),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const webhook = await ctx.db.webhook.create({
|
const webhook = await ctx.db.webhook.create({
|
||||||
data: {
|
data: buildWebhookCreateData(input),
|
||||||
name: input.name,
|
|
||||||
url: input.url,
|
|
||||||
...(input.secret !== undefined ? { secret: input.secret } : {}),
|
|
||||||
events: input.events,
|
|
||||||
isActive: input.isActive,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
void createAuditEntry({
|
void createAuditEntry({
|
||||||
@@ -66,30 +50,15 @@ export const webhookRouter = createTRPCRouter({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
data: z.object({
|
data: updateWebhookInputSchema,
|
||||||
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(),
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const existing = await ctx.db.webhook.findUnique({ where: { id: input.id } });
|
const existing = await loadWebhookOrThrow(ctx.db, input.id);
|
||||||
if (!existing) {
|
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Webhook not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await ctx.db.webhook.update({
|
const updated = await ctx.db.webhook.update({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
data: {
|
data: buildWebhookUpdateData(input.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 } : {}),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
void createAuditEntry({
|
void createAuditEntry({
|
||||||
@@ -111,10 +80,7 @@ export const webhookRouter = createTRPCRouter({
|
|||||||
delete: adminProcedure
|
delete: adminProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const existing = await ctx.db.webhook.findUnique({ where: { id: input.id } });
|
const existing = await loadWebhookOrThrow(ctx.db, input.id);
|
||||||
if (!existing) {
|
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Webhook not found" });
|
|
||||||
}
|
|
||||||
await ctx.db.webhook.delete({ where: { id: input.id } });
|
await ctx.db.webhook.delete({ where: { id: input.id } });
|
||||||
|
|
||||||
void createAuditEntry({
|
void createAuditEntry({
|
||||||
@@ -133,62 +99,8 @@ export const webhookRouter = createTRPCRouter({
|
|||||||
test: adminProcedure
|
test: adminProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const wh = await ctx.db.webhook.findUnique({ where: { id: input.id } });
|
const wh = await loadWebhookOrThrow(ctx.db, input.id);
|
||||||
if (!wh) {
|
const result = await sendWebhookTestRequest(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
void createAuditEntry({
|
void createAuditEntry({
|
||||||
db: ctx.db,
|
db: ctx.db,
|
||||||
|
|||||||
Reference in New Issue
Block a user