import { afterEach, describe, expect, it, vi } from "vitest"; import { didOf } from "#lib/api/syntax.ts"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import oauthMetadata from "#lib/oauth-client-metadata.ts"; import { missingPermissions } from "#lib/auth/scopes.ts"; vi.mock("#lib/auth/agent.ts", () => ({ createClient: () => ({}), mintServiceAuth: async () => "token", serviceDidForHost: (host: string) => `did:web:${host}`, hostForServiceDid: () => null })); vi.mock("#lib/api/knot.ts", () => ({ knotCapabilities: async () => null })); const { createWebhook, deleteWebhook, isWebhookUrl, listWebhookDeliveries, listWebhooks, retryWebhookDelivery, selectedWebhookEvents, updateWebhook, webhookActivation, webhookEventSelection, webhookIsActive, webhookPermissions } = await import("#lib/api/webhooks.ts"); const agent = { sub: "did:plc:nel" } as unknown as OAuthUserAgent; const spindle = new URL("https://spindle.test"); const target = { repoDid: didOf("did:plc:repo") }; const respond = (...bodies: unknown[]) => { let request = 0; const fetch = vi.fn(async () => { const body = bodies[request++] ?? {}; return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }); }); vi.stubGlobal("fetch", fetch); return fetch; }; const requestUrl = (fetch: ReturnType, index = 0) => new URL(String(fetch.mock.calls[index][0])); const requestBody = (fetch: ReturnType, index = 0) => JSON.parse(String(fetch.mock.calls[index][1]?.body)); afterEach(() => vi.unstubAllGlobals()); describe("webhook settings", () => { it("lists hooks and deliveries from the repository spindle", async () => { const hook = { id: "7", url: "https://hooks.test/tangled", events: ["push"], active: true, createdAt: "2026-09-20T00:00:00Z" }; const delivery = { id: "attempt", webhook: "7", event: "push", url: hook.url, success: true, createdAt: hook.createdAt }; const fetch = respond( { webhooks: [hook] }, // unpadded base64, as spindle encodes lexicon bytes { deliveries: [{ ...delivery, requestBody: { $bytes: "eyJvayI6dHJ1ZX0" } }] } ); await expect(listWebhooks(agent, spindle, target)).resolves.toEqual([hook]); await expect( listWebhookDeliveries(agent, spindle, target, ["7", "8"], 25) ).resolves.toEqual([{ ...delivery, requestBody: '{"ok":true}', responseBody: undefined }]); const listed = requestUrl(fetch); expect(listed.pathname).toBe("/xrpc/org.tangled.webhook.listWebhooks"); expect(listed.searchParams.get("repo")).toBe(target.repoDid); const deliveries = requestUrl(fetch, 1); expect(deliveries.pathname).toBe("/xrpc/org.tangled.webhook.listWebhookDeliveries"); expect(deliveries.searchParams.getAll("webhooks")).toEqual(["7", "8"]); expect(deliveries.searchParams.get("limit")).toBe("25"); }); it("creates, updates, retries, and deletes through spindle xrpc", async () => { const fetch = respond({ id: "7" }, {}, {}, {}); await expect( createWebhook(agent, spindle, target, { url: "https://hooks.test/tangled", events: ["push"], secret: "shared", active: true }) ).resolves.toBe("7"); await updateWebhook(agent, spindle, target, "7", { url: "https://hooks.test/renamed", events: ["repository:renamed"], secret: "" }); await retryWebhookDelivery(agent, spindle, target, "7", "attempt"); await deleteWebhook(agent, spindle, target, "7"); expect(fetch.mock.calls.map(([input]) => new URL(String(input)).pathname)).toEqual([ "/xrpc/org.tangled.webhook.createWebhook", "/xrpc/org.tangled.webhook.updateWebhook", "/xrpc/org.tangled.webhook.retryWebhookDelivery", "/xrpc/org.tangled.webhook.deleteWebhook" ]); expect(requestBody(fetch)).toEqual({ repo: target.repoDid, url: "https://hooks.test/tangled", events: ["push"], secret: "shared", active: true }); expect(requestBody(fetch, 1)).toEqual({ repo: target.repoDid, webhook: "7", url: "https://hooks.test/renamed", events: ["repository:renamed"], secret: "" }); expect(requestBody(fetch, 2)).toEqual({ repo: target.repoDid, webhook: "7", delivery: "attempt" }); }); it("maps event selections, permissions, and accepts only HTTP webhook URLs", () => { const selection = webhookEventSelection(["push", "pull_request:merged", "unknown"]); expect(selectedWebhookEvents(selection)).toEqual(["push", "pull_request:merged"]); expect(webhookActivation(true)).toBe("active"); expect(webhookActivation(false)).toBe("inactive"); expect(webhookIsActive("active")).toBe(true); expect(webhookIsActive("inactive")).toBe(false); expect(isWebhookUrl(" https://hooks.test/tangled ")).toBe(true); expect(isWebhookUrl("http://hooks.test/tangled")).toBe(true); expect(isWebhookUrl("ftp://hooks.test/tangled")).toBe(false); expect(isWebhookUrl("not a url")).toBe(false); const permissions = webhookPermissions(spindle); expect(permissions).toHaveLength(6); expect(missingPermissions("atproto", permissions)).toEqual(permissions); expect(missingPermissions(oauthMetadata.scope, permissions)).toEqual([]); expect(permissions).toContainEqual({ resource: "rpc", lxm: "org.tangled.webhook.listWebhooks", aud: "did:web:spindle.test" }); }); });