import { describe, expect, it, vi } from "vitest";
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
}));
const { sitesPermissions, getDeployHistory, loadSitePanel, branchChoices } =
await import("#lib/api/sites.ts");
const { createAppviewClient } = await import("#lib/api/appview.ts");
type AppviewContext = import("#lib/api/appview.ts").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<string, Reply>) =>
(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" }]);
});
});