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";
import type { Fetch } from "#lib/fetch.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<Fetch>(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<typeof respond>, index = 0) =>
new URL(String(fetch.mock.calls[index][0]));
const requestBody = (fetch: ReturnType<typeof respond>, 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"
});
});
});