import type { Did } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { getOrCreateDelegatedSession } from "$lib/api/actAs"; import type { BobbinContext } from "$lib/api/client"; import { REPO_COUNT } from "$lib/api/descriptors"; import { enrich, TYPE_COUNT } from "$lib/api/enrich"; import { leaveOrganization, listUserOrganizations, roleForScopes } from "$lib/api/organizations"; import { listControlledAccounts, removeController } from "$lib/api/tranquil"; import { forgetOrgDid } from "$lib/auth/accounts"; vi.mock("$lib/api/actAs", () => ({ getOrCreateDelegatedSession: vi.fn() })); vi.mock("$lib/api/tranquil", () => ({ listControlledAccounts: vi.fn(), removeController: vi.fn() })); vi.mock("$lib/auth/accounts", () => ({ forgetOrgDid: vi.fn() })); vi.mock(import("$lib/api/enrich"), async (importOriginal) => ({ ...(await importOriginal()), enrich: vi.fn() })); const OWNER_SCOPES = "atproto repo:* blob:*/* rpc:* identity:* account:*?action=manage transition:generic"; const EDITOR_SCOPES = "atproto repo:*?action=create blob:*/* rpc:*"; const controllerDid = "did:plc:controller" as Did; const controller = { sub: controllerDid } as OAuthUserAgent; const ctx = {} as BobbinContext; const tangledDid = "did:plc:tangled" as Did; const microcosmDid = "did:plc:microcosm" as Did; const profileUri = (did: Did) => `at://${did}/sh.tangled.actor.profile/self`; const countFor = (did: Did, count: number) => ({ [did]: { [REPO_COUNT.source]: { [TYPE_COUNT]: count } } }); beforeEach(() => { vi.resetAllMocks(); vi.mocked(listControlledAccounts).mockResolvedValue([ { did: tangledDid, handle: "tangled.org", grantedAt: "2025-01-14T09:00:00.000Z", grantedScopes: OWNER_SCOPES }, { did: microcosmDid, handle: "microcosm.blue", grantedAt: "2025-03-02T09:00:00.000Z", grantedScopes: EDITOR_SCOPES } ]); vi.mocked(enrich).mockResolvedValue({ output: { items: [ { uri: profileUri(tangledDid), value: { $type: "sh.tangled.actor.profile", bluesky: false, isOrganization: true, description: "Social coding." } }, { uri: profileUri(microcosmDid), value: { $type: "sh.tangled.actor.profile", bluesky: false, isOrganization: true } } ] }, data: { ...countFor(tangledDid, 24), ...countFor(microcosmDid, 9) } } as never); }); describe("roleForScopes", () => { it("reads account management as ownership", () => { expect(roleForScopes(OWNER_SCOPES)).toBe("owner"); }); it("treats every other grant as membership", () => { expect(roleForScopes(EDITOR_SCOPES)).toBe("member"); expect(roleForScopes("")).toBe("member"); }); }); describe("listUserOrganizations", () => { it("joins the controlled accounts with their profiles and repo counts", async () => { const organizations = await listUserOrganizations(controller, ctx); expect(vi.mocked(enrich).mock.calls[0][1]).toMatchObject({ xrpc: "sh.tangled.actor.getProfiles", params: { actors: [profileUri(tangledDid), profileUri(microcosmDid)] } }); expect(organizations).toEqual([ { did: tangledDid, handle: "tangled.org", description: "Social coding.", repos: 24, role: "owner", joinedAt: "2025-01-14T09:00:00.000Z" }, { did: microcosmDid, handle: "microcosm.blue", description: undefined, repos: 9, role: "member", joinedAt: "2025-03-02T09:00:00.000Z" } ]); }); it("skips delegated accounts that are not organizations", async () => { vi.mocked(enrich).mockResolvedValue({ output: { items: [ { uri: profileUri(microcosmDid), value: { $type: "sh.tangled.actor.profile", bluesky: false, isOrganization: false } } ] }, data: countFor(tangledDid, 24) } as never); const organizations = await listUserOrganizations(controller, ctx); expect(organizations.map((organization) => organization.did)).toEqual([tangledDid]); }); it("still lists the organizations when the appview is unreachable", async () => { vi.mocked(enrich).mockRejectedValue(new Error("bobbin is down")); const organizations = await listUserOrganizations(controller, ctx); expect(organizations).toHaveLength(2); expect(organizations[0]).toMatchObject({ handle: "tangled.org", repos: 0, role: "owner" }); }); it("does not reach for profiles when nothing is controlled", async () => { vi.mocked(listControlledAccounts).mockResolvedValue([]); await expect(listUserOrganizations(controller, ctx)).resolves.toEqual([]); expect(enrich).not.toHaveBeenCalled(); }); }); describe("leaveOrganization", () => { it("revokes the viewer's grant and forgets the organization", async () => { const signOut = vi.fn().mockResolvedValue(undefined); vi.mocked(getOrCreateDelegatedSession).mockResolvedValue({ signOut } as unknown as OAuthUserAgent); vi.mocked(removeController).mockResolvedValue({ success: true }); await leaveOrganization(controller, tangledDid); expect(getOrCreateDelegatedSession).toHaveBeenCalledWith(controller, tangledDid); expect(removeController).toHaveBeenCalledWith(expect.anything(), controllerDid); expect(signOut).toHaveBeenCalled(); expect(forgetOrgDid).toHaveBeenCalledWith(tangledDid); }); it("keeps the organization when the grant cannot be revoked", async () => { vi.mocked(getOrCreateDelegatedSession).mockResolvedValue({ signOut: vi.fn() } as unknown as OAuthUserAgent); vi.mocked(removeController).mockRejectedValue(new Error("Forbidden")); await expect(leaveOrganization(controller, tangledDid)).rejects.toThrow("Forbidden"); expect(forgetOrgDid).not.toHaveBeenCalled(); }); });