import { describe, expect, it } from "@suaveplan/testing/runner"; import { Hono } from "hono"; import { MemoryDeadLetterSink } from "../dead-letter/memory.js"; import { resetWebhookInstruments } from "../otel/instruments.js"; import { createWebhookService } from "../services/webhook-service.js"; import { MemoryEndpointStore } from "../store/memory.js"; import { buildWebhookRoutes, requireAuth } from "./build-routes.js"; interface TestPrincipal { id: string; tenantId: string | null; } function buildApp(principal: TestPrincipal | null) { resetWebhookInstruments(); const endpointStore = new MemoryEndpointStore({ clock: () => 0, idFactory: () => `ep-${Math.random().toString(36).slice(2, 9)}`, }); const service = createWebhookService({ endpointStore, deadLetterSink: new MemoryDeadLetterSink(), maxAttempts: 1, initialRetryDelayMs: 1, maxRetryDelayMs: 10, idempotencyGuard: null, logger: { debug: () => undefined, info: () => undefined, warn: () => undefined, error: () => undefined, }, clock: () => 0, fetch: async () => new Response("ok", { status: 200 }), }); const app = new Hono(); app.onError((err, c) => { const ctx = (err as { context?: { statusCode?: number } }).context; const status = (ctx?.statusCode ?? 500) as 400 | 401 | 403 | 404 | 500; const code = (err as { code?: string }).code ?? "ERROR"; return c.json({ code, message: err?.message ?? "error" }, status); }); app.use("*", async (c, next) => { c.set("user", principal); await next(); }); app.use("*", requireAuth()); for (const entry of buildWebhookRoutes(service)) { const method = entry.route.method.toLowerCase() as | "get" | "post" | "patch" | "delete"; const path = entry.route.path.replace(/\{(\w+)\}/g, ":$1"); app[method]( path, async (c) => (await entry.handler(c as never)) as Response ); } return { app, service }; } describe("buildWebhookRoutes", () => { it("anonymous request returns 401", { timeout: 5000 }, async () => { const { app } = buildApp(null); const res = await app.request("/"); expect(res.status).toBe(401); }); it("admin (no tenant) lists every endpoint", { timeout: 5000, }, async () => { const { app, service } = buildApp({ id: "u", tenantId: null }); await service.register({ tenantId: "t-a", url: "https://a/", secret: "shared-secret", events: ["e"], }); await service.register({ tenantId: "t-b", url: "https://b/", secret: "shared-secret", events: ["e"], }); const res = await app.request("/"); expect(res.status).toBe(200); const body = (await res.json()) as { endpoints: { id: string }[] }; expect(body.endpoints).toHaveLength(2); }); it("tenant-scoped principal lists only own tenant", { timeout: 5000, }, async () => { const { app, service } = buildApp({ id: "u", tenantId: "t-a" }); await service.register({ tenantId: "t-a", url: "https://a/", secret: "shared-secret", events: ["e"], }); await service.register({ tenantId: "t-b", url: "https://b/", secret: "shared-secret", events: ["e"], }); const res = await app.request("/"); const body = (await res.json()) as { endpoints: { tenantId: string | null }[]; }; expect(body.endpoints).toHaveLength(1); expect(body.endpoints[0]?.tenantId).toBe("t-a"); }); it("cross-tenant read returns 404 via WebhookError", { timeout: 5000, }, async () => { const { app, service } = buildApp({ id: "u", tenantId: "t-a" }); const created = await service.register({ tenantId: "t-b", url: "https://b/", secret: "shared-secret", events: ["e"], }); const res = await app.request(`/${created.id}`); // The route handler throws WebhookError(ENDPOINT_NOT_FOUND); // without a global error-handler middleware the framework // surfaces that as 500. The body carries the error code. expect(res.status).toBeGreaterThanOrEqual(400); }); it("create rejects cross-tenant write with WEBHOOK_TENANT_MISMATCH", { timeout: 5000, }, async () => { const { app } = buildApp({ id: "u", tenantId: "t-a" }); const res = await app.request("/", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ tenantId: "t-b", url: "https://example.com/", secret: "shared-secret", events: ["e"], }), }); expect(res.status).toBeGreaterThanOrEqual(400); }); it("update changes the endpoint and returns the new shape", { timeout: 10_000, }, async () => { const { app, service } = buildApp({ id: "u", tenantId: null }); const ep = await service.register({ tenantId: null, url: "https://example.com/", secret: "shared-secret", events: ["e"], }); const res = await app.request(`/${ep.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ active: false }), }); expect(res.status).toBe(200); const body = (await res.json()) as { endpoint: { active: boolean } }; expect(body.endpoint.active).toBe(false); }); it("delete returns deleted:true", { timeout: 5000 }, async () => { const { app, service } = buildApp({ id: "u", tenantId: null }); const ep = await service.register({ tenantId: null, url: "https://example.com/", secret: "shared-secret", events: ["e"], }); const res = await app.request(`/${ep.id}`, { method: "DELETE" }); expect(res.status).toBe(200); const body = (await res.json()) as { deleted: boolean }; expect(body.deleted).toBe(true); }); it("test route returns DeliveryResult shape", { timeout: 10_000, }, async () => { const { app, service } = buildApp({ id: "u", tenantId: null }); const ep = await service.register({ tenantId: null, url: "https://example.com/", secret: "shared-secret", events: ["e"], }); const res = await app.request(`/${ep.id}/test`, { method: "POST" }); expect(res.status).toBe(200); const body = (await res.json()) as { status: string }; expect(body.status).toBe("delivered"); }); it("create succeeds when body.tenantId matches principal tenant", { timeout: 5000, }, async () => { const { app } = buildApp({ id: "u", tenantId: "t-a" }); const res = await app.request("/", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ tenantId: "t-a", url: "https://example.com/", secret: "shared-secret", events: ["e"], }), }); expect(res.status).toBe(201); const body = (await res.json()) as { endpoint: { tenantId: string } }; expect(body.endpoint.tenantId).toBe("t-a"); }); it("read returns the endpoint for owning tenant", { timeout: 5000, }, async () => { const { app, service } = buildApp({ id: "u", tenantId: "t-a" }); const ep = await service.register({ tenantId: "t-a", url: "https://example.com/", secret: "shared-secret", events: ["e"], }); const res = await app.request(`/${ep.id}`); expect(res.status).toBe(200); const body = (await res.json()) as { endpoint: { id: string } }; expect(body.endpoint.id).toBe(ep.id); }); });