import type { Did } from "@atcute/lexicons/syntax"; import type { Session } from "@atcute/oauth-browser-client"; import { LoginError, OAuthResponseError, OAuthUserAgent, TokenRefreshError, deleteStoredSession, finalizeAuthorization, getSession } from "@atcute/oauth-browser-client"; import { type Mock, beforeEach, describe, expect, it, vi } from "vitest"; import { createAuth } from "$lib/auth.svelte"; interface MockAgent { sub: string; getSession: Mock; } vi.mock("@atcute/oauth-browser-client", async (importOriginal) => { const mod = await importOriginal>(); class MockOAuthUserAgent { static instances: MockOAuthUserAgent[] = []; readonly sub: string; getSession = vi.fn(async () => this.session); signOut = vi.fn(async () => {}); constructor(readonly session: { info: { sub: string } }) { this.sub = session.info.sub; MockOAuthUserAgent.instances.push(this); } } return { ...mod, OAuthUserAgent: MockOAuthUserAgent, configureOAuth: vi.fn(), createAuthorizationUrl: vi.fn(), finalizeAuthorization: vi.fn(), getSession: vi.fn(), listStoredSessions: vi.fn(() => []), deleteStoredSession: vi.fn() }; }); // the vi.mock above swaps in a class that records its instances const MockedUserAgent = OAuthUserAgent as unknown as { instances: MockAgent[] }; const mockedGetSession = vi.mocked(getSession); const mockedDeleteStoredSession = vi.mocked(deleteStoredSession); const mockedFinalize = vi.mocked(finalizeAuthorization); const alice = "did:plc:alice" as Did; const liveSession = (did: Did): Session => ({ info: { sub: did } }) as unknown as Session; beforeEach(() => { vi.clearAllMocks(); MockedUserAgent.instances.length = 0; }); describe("agentFor", () => { it("returns a live agent and reuses it", async () => { mockedGetSession.mockResolvedValue(liveSession(alice)); const auth = createAuth("http://127.0.0.1:1", null); const agent = await auth.agentFor(alice); expect(agent.sub).toBe(alice); expect(await auth.agentFor(alice)).toBe(agent); expect(mockedGetSession).toHaveBeenCalledTimes(1); expect(mockedGetSession.mock.calls[0]).toEqual([alice]); expect(MockedUserAgent.instances[0].getSession).toHaveBeenCalledTimes(1); }); it("prunes a revoked session and throws a friendly error", async () => { mockedGetSession.mockRejectedValue(new TokenRefreshError(alice, "session was revoked")); const auth = createAuth("http://127.0.0.1:1", null); await expect(auth.agentFor(alice)).rejects.toThrow(/session expired/); expect(mockedDeleteStoredSession).toHaveBeenCalledWith(alice); }); it("treats an invalid_token response from the token endpoint as dead", async () => { mockedGetSession.mockRejectedValue( new OAuthResponseError(new Response(null, { status: 400 }), { error: "invalid_token", error_description: '"exp" claim timestamp check failed' }) ); const auth = createAuth("http://127.0.0.1:1", null); await expect(auth.agentFor(alice)).rejects.toThrow(/session expired/); expect(mockedDeleteStoredSession).toHaveBeenCalledWith(alice); }); it("keeps the session on transient failures", async () => { const cause = new TypeError("fetch failed"); mockedGetSession.mockRejectedValue(cause); const auth = createAuth("http://127.0.0.1:1", null); await expect(auth.agentFor(alice)).rejects.toBe(cause); expect(mockedDeleteStoredSession).not.toHaveBeenCalled(); }); it("prunes a cached agent whose session died since it was minted", async () => { mockedGetSession.mockResolvedValue(liveSession(alice)); const auth = createAuth("http://127.0.0.1:1", null); await auth.agentFor(alice); MockedUserAgent.instances[0].getSession.mockRejectedValue( new TokenRefreshError(alice, "session was revoked") ); await expect(auth.agentFor(alice)).rejects.toThrow(/session expired/); expect(mockedDeleteStoredSession).toHaveBeenCalledWith(alice); }); }); describe("completePendingSignIn", () => { const seeded = { did: alice, handle: "alice.example" }; it("adopts the exchanged session without disturbing the seeded profile", async () => { mockedFinalize.mockResolvedValue({ session: liveSession(alice), state: null }); const auth = createAuth("http://127.0.0.1:1", seeded); const completion = auth.completePendingSignIn("#state=sid&code=c&iss=https://pds.example"); // the topbar must not drop to the skeleton mid-exchange expect(auth.resolving).toBe(false); expect(auth.currentUser).toEqual({ did: alice, handle: "alice.example" }); await completion; expect(auth.currentUser).toEqual({ did: alice, handle: "alice.example" }); expect(mockedFinalize).toHaveBeenCalledTimes(1); expect(mockedFinalize.mock.calls[0][0].get("code")).toBe("c"); }); it("drops the seeded account and rethrows when the exchange fails", async () => { mockedFinalize.mockRejectedValue(new LoginError("unknown state provided")); const auth = createAuth("http://127.0.0.1:1", seeded); await expect(auth.completePendingSignIn("#state=sid&code=c")).rejects.toThrow( /unknown state/ ); expect(auth.state.kind).toBe("logged-out"); expect(auth.currentUser).toBeNull(); }); it("blocks agentFor until the pending exchange has stored the session", async () => { const gate = Promise.withResolvers<{ session: Session; state: null }>(); mockedFinalize.mockReturnValue(gate.promise); mockedGetSession.mockResolvedValue(liveSession(alice)); const auth = createAuth("http://127.0.0.1:1", seeded); const completion = auth.completePendingSignIn("#state=sid&code=c"); const agentPromise = auth.agentFor(alice); // flush microtasks so agentFor reaches its park point on the exchange for (let i = 0; i < 5; i++) await Promise.resolve(); expect(mockedGetSession).not.toHaveBeenCalled(); gate.resolve({ session: liveSession(alice), state: null }); await completion; // the adopted session satisfies agentFor without a storage reload expect((await agentPromise).sub).toBe(alice); expect(mockedGetSession).not.toHaveBeenCalled(); }); });