import { afterEach, describe, expect, it, vi } from "vitest"; import type { Did } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import oauthMetadata from "$lib/oauth-client-metadata"; import { missingPermissions } from "$lib/auth/scopes"; vi.mock("$lib/auth/agent", () => ({ createClient: () => ({}), mintServiceAuth: async () => "token", serviceDidForHost: (host: string) => `did:web:${host}`, hostForServiceDid: () => null })); const { createWebhook, deleteWebhook, isWebhookUrl, listWebhookDeliveries, listWebhooks, retryWebhookDelivery, selectedWebhookEvents, toggleWebhook, updateWebhook, webhookActivation, webhookEventSelection, webhookIsActive, webhookPermissions } = await import("$lib/api/webhooks"); const agent = { sub: "did:plc:owner" } as unknown as OAuthUserAgent; const spindle = new URL("https://spindle.test"); const repoDid: Did = "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: 11, webhookId: 7, deliveryId: "attempt", event: "push", url: hook.url, success: true, createdAt: hook.createdAt }; const fetch = respond({ webhooks: [hook] }, { deliveries: [delivery] }); await expect(listWebhooks(agent, spindle, repoDid)).resolves.toEqual([hook]); await expect(listWebhookDeliveries(agent, spindle, repoDid, [7, 8], 25)).resolves.toEqual([ delivery ]); const listed = requestUrl(fetch); expect(listed.pathname).toBe("/xrpc/org.tangled.temp.webhook.listWebhooks"); expect(listed.searchParams.get("repoDid")).toBe(repoDid); const deliveries = requestUrl(fetch, 1); expect(deliveries.pathname).toBe("/xrpc/org.tangled.temp.webhook.getDeliveriesForWebhooks"); expect(deliveries.searchParams.getAll("ids")).toEqual(["7", "8"]); expect(deliveries.searchParams.get("limit")).toBe("25"); }); it("creates, updates, toggles, retries, and deletes through spindle xrpc", async () => { const fetch = respond({ id: 7 }, {}, { active: false }, {}, {}); await expect( createWebhook(agent, spindle, repoDid, { url: "https://hooks.test/tangled", events: ["push"], secret: "shared", active: true }) ).resolves.toBe(7); await updateWebhook(agent, spindle, repoDid, 7, { url: "https://hooks.test/renamed", events: ["repository:renamed"], secret: "" }); await expect(toggleWebhook(agent, spindle, repoDid, 7)).resolves.toBe(false); await retryWebhookDelivery(agent, spindle, repoDid, 7, "attempt"); await deleteWebhook(agent, spindle, repoDid, 7); expect(fetch.mock.calls.map(([input]) => new URL(String(input)).pathname)).toEqual([ "/xrpc/org.tangled.temp.webhook.createWebhook", "/xrpc/org.tangled.temp.webhook.updateWebhook", "/xrpc/org.tangled.temp.webhook.toggleWebhook", "/xrpc/org.tangled.temp.webhook.retryDelivery", "/xrpc/org.tangled.temp.webhook.deleteWebhook" ]); expect(requestBody(fetch)).toEqual({ repoDid, url: "https://hooks.test/tangled", events: ["push"], secret: "shared", active: true }); expect(requestBody(fetch, 1)).toEqual({ repoDid, id: 7, url: "https://hooks.test/renamed", events: ["repository:renamed"], secret: "" }); expect(requestBody(fetch, 3)).toEqual({ repoDid, webhookId: 7, deliveryId: "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(7); expect(missingPermissions("atproto", permissions)).toEqual(permissions); expect(missingPermissions(oauthMetadata.scope, permissions)).toEqual([]); expect(permissions).toContainEqual({ resource: "rpc", lxm: "org.tangled.temp.webhook.listWebhooks", aud: "did:web:spindle.test" }); }); });