import { describe, expect, it, vi } from "vitest"; 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 { sitesPermissions, getDeployHistory, loadSitePanel, branchChoices } = await import("$lib/api/sites"); const { createAppviewClient } = await import("$lib/api/appview"); type AppviewContext = import("$lib/api/appview").AppviewContext; const sites = new URL("https://sites.test"); const agent = { sub: "did:plc:alice" } as unknown as AppviewContext["agent"]; interface Reply { status?: number; body?: unknown; } const ctxFor = (reply: (url: URL) => Reply, seen: URL[]): AppviewContext => createAppviewClient({ apiUrl: "https://sites.test", agent, fetch: async (input) => { const url = input as URL; seen.push(url); const { status = 200, body = {} } = reply(url); return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); } }); const nsidOf = (url: URL) => url.pathname.replace("/xrpc/", ""); const called = (seen: URL[]) => seen.map(nsidOf); const routes = (table: Record) => (url: URL): Reply => table[nsidOf(url)] ?? { body: {} }; const DOMAIN_CLAIM = "org.tangled.temp.site.getDomainClaim"; const SITE_CONFIG = "org.tangled.temp.repo.getSiteConfig"; const DEPLOY_HISTORY = "org.tangled.temp.repo.getDeployHistory"; const NO_SITE = { status: 404, body: { error: "SiteNotFound", message: "no site configuration for this repository" } }; const config = { branch: "site", dir: "/docs", isIndex: false }; const entry = { status: "failure", trigger: "push", branch: "main", dir: "/", commitSha: "0c4d0e9b", error: "knot unreachable", createdAt: "2026-07-01T10:00:00Z" }; describe("sites permissions", () => { it("covers every method the repo sites panel calls, the deploy history included", () => { const permissions = sitesPermissions(sites); expect(permissions).toHaveLength(5); expect(missingPermissions("atproto", permissions)).toEqual(permissions); expect(missingPermissions(oauthMetadata.scope, permissions)).toEqual([]); expect(permissions).toContainEqual({ resource: "rpc", lxm: "org.tangled.temp.repo.deploySite", aud: "did:web:sites.test" }); expect(permissions).toContainEqual({ resource: "rpc", lxm: "org.tangled.temp.repo.getDeployHistory", aud: "did:web:sites.test" }); }); }); describe("getDeployHistory", () => { it("asks sites for the repo's deploys", async () => { const seen: URL[] = []; const ctx = ctxFor(() => ({ body: { deploys: [entry] } }), seen); const history = await getDeployHistory(ctx, "did:plc:repo"); expect(history.deploys).toEqual([entry]); expect(seen[0].pathname).toBe("/xrpc/org.tangled.temp.repo.getDeployHistory"); expect(seen[0].searchParams.get("repoDid")).toBe("did:plc:repo"); }); it("reads a payload without the deploys key as an empty history", async () => { expect( await getDeployHistory( ctxFor(() => ({}), []), "did:plc:repo" ) ).toEqual({ deploys: [] }); }); it("still refuses a history read for a repo with no site config", async () => { const ctx = ctxFor(() => NO_SITE, []); await expect(getDeployHistory(ctx, "did:plc:repo")).rejects.toMatchObject({ status: 404, error: "SiteNotFound" }); }); }); describe("loadSitePanel", () => { it("reads the domain, config and deploys of a configured site", async () => { const seen: URL[] = []; const ctx = ctxFor( routes({ [DOMAIN_CLAIM]: { body: { domain: "alice.sites.test" } }, [SITE_CONFIG]: { body: { config } }, [DEPLOY_HISTORY]: { body: { deploys: [entry] } } }), seen ); await expect(loadSitePanel(ctx, "did:plc:repo")).resolves.toEqual({ domain: "alice.sites.test", config, deploys: [entry] }); expect(called(seen).sort()).toEqual([DOMAIN_CLAIM, SITE_CONFIG, DEPLOY_HISTORY].sort()); }); it("loads an unconfigured repo without asking for a history it cannot have", async () => { const seen: URL[] = []; const ctx = ctxFor( routes({ [DOMAIN_CLAIM]: { body: { domain: "alice.sites.test" } }, [SITE_CONFIG]: { body: {} }, [DEPLOY_HISTORY]: NO_SITE }), seen ); await expect(loadSitePanel(ctx, "did:plc:repo")).resolves.toEqual({ domain: "alice.sites.test", config: null, deploys: [] }); expect(called(seen)).not.toContain(DEPLOY_HISTORY); }); it("loads for an account with no domain claim", async () => { const ctx = ctxFor(routes({ [SITE_CONFIG]: { body: {} } }), []); await expect(loadSitePanel(ctx, "did:plc:repo")).resolves.toEqual({ domain: null, config: null, deploys: [] }); }); it("loads a configured site whose domain claim lapsed, without asking for its history", async () => { const seen: URL[] = []; const ctx = ctxFor( routes({ [SITE_CONFIG]: { body: { config } }, [DEPLOY_HISTORY]: NO_SITE }), seen ); await expect(loadSitePanel(ctx, "did:plc:repo")).resolves.toEqual({ domain: null, config, deploys: [] }); expect(called(seen)).not.toContain(DEPLOY_HISTORY); }); it("leaves a failed config read on the panel", async () => { const ctx = ctxFor( routes({ [SITE_CONFIG]: { status: 500, body: { error: "InternalServerError", message: "boom" } } }), [] ); await expect(loadSitePanel(ctx, "did:plc:repo")).rejects.toMatchObject({ status: 500 }); }); it("keeps the owner refusal of a configured site's history", async () => { const ctx = ctxFor( routes({ [DOMAIN_CLAIM]: { body: { domain: "alice.sites.test" } }, [SITE_CONFIG]: { body: { config } }, [DEPLOY_HISTORY]: { status: 403, body: { error: "NotOwner", message: "Only the repository owner can read this site's deploy history." } } }), [] ); await expect(loadSitePanel(ctx, "did:plc:repo")).rejects.toMatchObject({ status: 403, error: "NotOwner" }); }); it("leaves a failed domain read on the panel", async () => { const ctx = ctxFor( routes({ [DOMAIN_CLAIM]: { status: 502, body: { error: "UpstreamFailed", message: "no pds" } } }), [] ); await expect(loadSitePanel(ctx, "did:plc:repo")).rejects.toMatchObject({ status: 502 }); }); }); describe("branchChoices", () => { it("offers every branch the repo lists", () => { expect(branchChoices("main", ["main", "preview", "docs"])).toEqual([ { value: "main" }, { value: "preview" }, { value: "docs" } ]); }); it("keeps a branch the repo no longer lists at the top, without repeating it", () => { expect(branchChoices("docs", ["main", "docs"])).toEqual([ { value: "main" }, { value: "docs" } ]); expect(branchChoices("gh-pages", ["main", "docs"])).toEqual([ { value: "gh-pages" }, { value: "main" }, { value: "docs" } ]); }); it("offers nothing when neither the config nor the knot named a branch", () => { expect(branchChoices("", [])).toEqual([]); expect(branchChoices("", ["main"])).toEqual([{ value: "main" }]); }); });