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 { cached, invalidate } from "$lib/api/cache"; import type { BobbinContext } from "$lib/api/client"; import { listDelegatedAccounts } from "$lib/api/delegation"; import { leaveOrganization, listUserOrganizations, viewerControlsOrganization } from "$lib/api/organizations"; import { getProfiles } from "$lib/api/records"; import { removeController } from "$lib/api/tranquil"; import { forgetOrgDid } from "$lib/auth/accounts"; vi.mock("$lib/api/actAs", () => ({ getOrCreateDelegatedSession: vi.fn() })); vi.mock("$lib/api/delegation", () => ({ listDelegatedAccounts: vi.fn() })); vi.mock("$lib/api/tranquil", () => ({ removeController: vi.fn() })); vi.mock("$lib/auth/accounts", () => ({ forgetOrgDid: vi.fn() })); vi.mock("$lib/api/records", () => ({ getProfiles: vi.fn() })); vi.mock("$lib/api/cache", () => ({ cached: vi.fn((_key: string, _ttl: number, fn: () => Promise) => fn()), invalidate: 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 tranquilDid = "did:web:tranquil.example"; 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`; beforeEach(() => { vi.resetAllMocks(); vi.mocked(listDelegatedAccounts).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(getProfiles).mockResolvedValue({ items: [ { uri: profileUri(tangledDid), value: { $type: "sh.tangled.actor.profile", bluesky: false, isOrganization: true } }, { uri: profileUri(microcosmDid), value: { $type: "sh.tangled.actor.profile", bluesky: false, isOrganization: true } } ] } as never); }); describe("listUserOrganizations", () => { it("lists the controlled accounts with the viewer's role", async () => { const organizations = await listUserOrganizations(controller, ctx, tranquilDid); expect(listDelegatedAccounts).toHaveBeenCalledWith(controller, tranquilDid); expect(getProfiles).toHaveBeenCalledWith( ctx, [profileUri(tangledDid), profileUri(microcosmDid)], undefined ); expect(organizations).toEqual([ { did: tangledDid, handle: "tangled.org", role: "owner", joinedAt: "2025-01-14T09:00:00.000Z" }, { did: microcosmDid, handle: "microcosm.blue", role: "member", joinedAt: "2025-03-02T09:00:00.000Z" } ]); }); it("skips delegated accounts that are not organizations", async () => { vi.mocked(getProfiles).mockResolvedValue({ items: [ { uri: profileUri(microcosmDid), value: { $type: "sh.tangled.actor.profile", bluesky: false, isOrganization: false } } ] } as never); const organizations = await listUserOrganizations(controller, ctx, tranquilDid); expect(organizations.map((organization) => organization.did)).toEqual([tangledDid]); }); it("still lists the organizations when the appview is unreachable", async () => { vi.mocked(getProfiles).mockRejectedValue(new Error("bobbin is down")); const organizations = await listUserOrganizations(controller, ctx, tranquilDid); expect(organizations).toHaveLength(2); expect(organizations[0]).toMatchObject({ handle: "tangled.org", role: "owner" }); }); it("does not reach for profiles when nothing is controlled", async () => { vi.mocked(listDelegatedAccounts).mockResolvedValue([]); await expect(listUserOrganizations(controller, ctx, tranquilDid)).resolves.toEqual([]); expect(getProfiles).not.toHaveBeenCalled(); }); }); describe("viewerControlsOrganization", () => { it("is true only for organizations tranquil lists for the viewer", async () => { await expect(viewerControlsOrganization(controller, tranquilDid, tangledDid)).resolves.toBe( true ); await expect( viewerControlsOrganization(controller, tranquilDid, "did:plc:stranger") ).resolves.toBe(false); expect(listDelegatedAccounts).toHaveBeenCalledWith(controller, tranquilDid); }); it("shares one cached listing with the organizations list", async () => { await listUserOrganizations(controller, ctx, tranquilDid); await viewerControlsOrganization(controller, tranquilDid, tangledDid); const keys = vi.mocked(cached).mock.calls.map(([key]) => key); expect(keys).toHaveLength(2); expect(new Set(keys).size).toBe(1); expect(keys[0]).toContain(controllerDid); expect(keys[0]).toContain(tranquilDid); }); it("surfaces a failed listing instead of denying quietly", async () => { vi.mocked(listDelegatedAccounts).mockRejectedValue(new Error("tranquil is down")); await expect( viewerControlsOrganization(controller, tranquilDid, tangledDid) ).rejects.toThrow("tranquil is down"); }); }); 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, tranquilDid); expect(getOrCreateDelegatedSession).toHaveBeenCalledWith(controller, tangledDid); expect(removeController).toHaveBeenCalledWith(expect.anything(), controllerDid); expect(signOut).toHaveBeenCalled(); expect(forgetOrgDid).toHaveBeenCalledWith(tangledDid); await listUserOrganizations(controller, ctx, tranquilDid); expect(invalidate).toHaveBeenCalledWith(vi.mocked(cached).mock.calls[0][0]); }); 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, tranquilDid)).rejects.toThrow( "Forbidden" ); expect(forgetOrgDid).not.toHaveBeenCalled(); expect(invalidate).not.toHaveBeenCalled(); }); });