import { describe, expect, it, vi } from "vitest"; import { ClientResponseError } from "#lib/api/client.ts"; import { classifyRepoAvailability, withAuthorHandles, withImportAvailability, type RepoAvailability } from "#lib/api/repoIndex.ts"; import type { ImportState } from "#lib/api/repo.ts"; import { sshFingerprint } from "#lib/api/settings.ts"; import type { CommitSummary } from "#lib/components/repo/types.ts"; import type { Fetch } from "#lib/fetch.ts"; const notSynced = () => new ClientResponseError({ status: 404, data: { error: "RepoNotFound" } }); describe("classifyRepoAvailability", () => { it("recognizes a repo the mirror has not synced yet", () => { const result = classifyRepoAvailability( [notSynced(), notSynced(), notSynced()].map((cause) => ({ value: null, error: cause })) ); expect(result).toBe("not-synced"); }); it("does not treat other 404s as not synced", () => { const result = classifyRepoAvailability( ["tree", "log", "branches"].map(() => ({ value: null, error: new ClientResponseError({ status: 404, data: { error: "XRPCNotSupported" } }) })) ); expect(result).toBe("error"); }); it("keeps transport failures as errors", () => { const result = classifyRepoAvailability( ["tree", "log", "branches"].map(() => ({ value: null, error: new TypeError("offline") })) ); expect(result).toBe("error"); }); it("does not mistake a missing ref for an unsynced repo", () => { const result = classifyRepoAvailability([ { value: null, error: notSynced() }, { value: null, error: notSynced() }, { value: { branches: [] }, error: null } ]); expect(result).toBe("ok"); }); }); describe("withImportAvailability", () => { it.each([ ["ok", { content: "fetching" as const }, "ok"], ["ok", { content: "partial" as const }, "ok"], ["ok", { content: "failed" as const }, "ok"], ["not-synced", null, "not-synced"], ["not-synced", { content: "fetching" as const }, "importing"], ["not-synced", { content: "partial" as const }, "importing"], ["not-synced", { content: "failed" as const }, "import-failed"], ["empty", null, "empty"], ["error", null, "error"], ["empty", { content: "failed" as const }, "import-failed"], ["error", { content: "failed" as const }, "import-failed"], ["empty", { content: "fetching" as const }, "importing"], ["empty", { content: "partial" as const }, "importing"], ["error", { content: "fetching" as const }, "importing"], ["error", { content: "partial" as const }, "importing"] ] satisfies [RepoAvailability, ImportState | null, RepoAvailability][])( "maps (%s, %j) -> %s", (availability, status, expected) => { expect(withImportAvailability(availability, status)).toBe(expected); } ); }); const commit = (c: Partial): CommitSummary => ({ hash: "h", shortHash: "h", subject: "s", body: "", authorName: "", authorEmail: "", when: "2026-01-01T00:00:00Z", ...c }); // dispatches the two upstreams withAuthorHandles touches: the internal kv // route (relative) and bobbin's resolveMiniDoc. const fetchMock = vi.fn(async (input: string | URL, _init?: RequestInit) => { const url = String(input); if (url.startsWith("/_internal/email-did")) { return new Response( JSON.stringify({ "alice@example.com": "did:plc:alice", "bob@example.com": "did:plc:bob", "MIXED@Example.com": "did:plc:mixed", "ghost@example.com": null }) ); } if (url.includes("blue.microcosm.identity.resolveMiniDoc")) { const did = new URL(url).searchParams.get("identifier") ?? ""; return new Response(JSON.stringify({ did, handle: `${did.replace("did:plc:", "")}.test` })); } return new Response("unexpected", { status: 404 }); }); describe("withAuthorHandles", () => { it("resolves verified emails to a did before hydrating handles", async () => { const commits = [ commit({ authorName: "Alice", authorEmail: "alice@example.com" }), commit({ authorName: "Signed", authorEmail: "did:plc:signed", authorDid: "did:plc:signed" }) ]; const out = await withAuthorHandles(commits, "https://bobbin.test", fetchMock as Fetch); expect(out[0]).toMatchObject({ authorDid: "did:plc:alice", authorHandle: "alice.test" }); expect(out[1]).toMatchObject({ authorDid: "did:plc:signed", authorHandle: "signed.test" }); }); it("keeps the requested email case when looking up the kv result", async () => { const out = await withAuthorHandles( [commit({ authorEmail: "MIXED@Example.com" })], "https://bobbin.test", fetchMock as Fetch ); expect(out[0]).toMatchObject({ authorDid: "did:plc:mixed", authorHandle: "mixed.test" }); }); it("leaves unresolved emails as plain authors", async () => { const out = await withAuthorHandles( [commit({ authorEmail: "ghost@example.com" })], "https://bobbin.test", fetchMock as Fetch ); expect(out[0].authorDid).toBeUndefined(); expect(out[0].authorHandle).toBeUndefined(); }); it("fails open when the kv route errors", async () => { const broken = vi.fn(async () => new Response("nope", { status: 500 })); const out = await withAuthorHandles( [commit({ authorEmail: "alice@example.com" })], "https://bobbin.test", broken as Fetch ); expect(out[0].authorDid).toBeUndefined(); }); it("keeps a did from the email pass when handle resolution fails", async () => { const brokenBobbin = vi.fn(async (input: string | URL) => { if (String(input).startsWith("/_internal/email-did")) { return new Response(JSON.stringify({ "alice@example.com": "did:plc:alice" })); } return new Response("nope", { status: 500 }); }); const out = await withAuthorHandles( [commit({ authorEmail: "alice@example.com" })], "https://bobbin.test", brokenBobbin as Fetch ); expect(out[0]).toMatchObject({ authorDid: "did:plc:alice" }); expect(out[0].authorHandle).toBeUndefined(); }); }); describe("withAuthorHandles signatures", () => { const signKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHEisipfUi+zRyW1reo72Rt4+EUbM5mKZrI9/PkyHMpR"; const authKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBKTt0Ub2mIVQbZG0DL1fB6vk1PB8iI5bmNTe7IfBnMO"; const keysFetch = vi.fn(async (input: string | URL, init?: RequestInit) => { if (!String(input).includes("org.tangled.publicKey.listKeys")) return fetchMock(input, init); const subject = new URL(String(input)).searchParams.get("subject"); const items = subject === "did:plc:alice" ? [ { value: { publicKey: signKey } }, { value: { publicKey: authKey, usages: ["auth"] } } ].map((item, i) => ({ uri: `at://${subject}/org.tangled.key.sshKey/${i}`, ...item })) : []; return new Response(JSON.stringify({ items })); }); const verifiedFor = async ( committerEmail: string, key: string | null, authorEmail = "bob@example.com" ) => { const [out] = await withAuthorHandles( [commit({ authorEmail, committerEmail, signatureFingerprint: key ?? undefined })], "https://bobbin.test", keysFetch as Fetch ); return out; }; it("verifies a signature made with one of the committer's signing keys", async () => { const out = await verifiedFor("alice@example.com", await sshFingerprint(signKey)); expect(out).toMatchObject({ authorDid: "did:plc:bob", committerDid: "did:plc:alice", committerHandle: "alice.test", signature: "verified" }); }); it("still maps the author when the signature does not verify", async () => { for (const key of ["SHA256:someoneelse", await sshFingerprint(authKey)]) { const out = await verifiedFor("alice@example.com", key); expect(out).toMatchObject({ authorDid: "did:plc:bob", committerDid: "did:plc:alice", signature: "unverified" }); } const notTheirs = await verifiedFor("bob@example.com", await sshFingerprint(signKey)); expect(notTheirs.signature).toBe("unverified"); }); it("leaves unsigned commits without a signature state", async () => { const out = await verifiedFor("alice@example.com", null); expect(out).toMatchObject({ authorDid: "did:plc:bob", committerDid: "did:plc:alice", committerHandle: "alice.test" }); expect(out.signature).toBeUndefined(); }); });