Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231import 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, refreshSessionCandidates, resumeSignIn } from "$lib/auth.svelte";import { consumePendingCallback } from "$lib/auth/pending";
interface MockAgent { sub: string; getSession: Mock;}
vi.mock("@atcute/oauth-browser-client", async (importOriginal) => { const mod = await importOriginal<Record<string, unknown>>(); 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() };});
vi.mock("$lib/auth/pending", () => ({ consumePendingCallback: vi.fn(), writePendingLogin: vi.fn()}));
// the vi.mock above swaps in a class that records its instancesconst MockedUserAgent = OAuthUserAgent as unknown as { instances: MockAgent[] };
const mockedGetSession = vi.mocked(getSession);const mockedDeleteStoredSession = vi.mocked(deleteStoredSession);const mockedFinalize = vi.mocked(finalizeAuthorization);const mockedConsumePending = vi.mocked(consumePendingCallback);
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("refreshSessionCandidates", () => { it("excludes delegated organization sessions even when one was active", () => { const org = "did:plc:org" as Did; const bob = "did:plc:bob" as Did;
expect(refreshSessionCandidates(org, org, [org, alice, bob], [org])).toEqual([alice, bob]); });
it("deduplicates personal session candidates in priority order", () => { const bob = "did:plc:bob" as Did;
expect(refreshSessionCandidates(alice, bob, [bob, alice], [])).toEqual([alice, bob]); });});
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(); });});
describe("resumeSignIn", () => { const seeded = { did: alice, handle: "alice.example" }; const stash = { hash: "#state=sid&code=c", login: { did: alice, handle: "alice.example", returnTo: "/", at: Date.now() } };
it("starts the exchange before it returns, so agentFor has something to park on", () => { mockedConsumePending.mockReturnValue(stash); mockedFinalize.mockReturnValue(Promise.withResolvers<never>().promise); const auth = createAuth("http://127.0.0.1:1", seeded);
const resumed = resumeSignIn(auth, "/");
expect(mockedFinalize).toHaveBeenCalledTimes(1); expect(resumed).toMatchObject({ handle: "alice.example", returnTo: "/" }); });
it("leaves the callback route to run its own exchange", () => { mockedConsumePending.mockReturnValue(stash); const auth = createAuth("http://127.0.0.1:1", seeded);
expect(resumeSignIn(auth, "/oauth/callback")).toBeNull(); expect(mockedConsumePending).not.toHaveBeenCalled(); });
it("does nothing without a stashed callback", () => { mockedConsumePending.mockReturnValue(null); const auth = createAuth("http://127.0.0.1:1", null);
expect(resumeSignIn(auth, "/")).toBeNull(); expect(mockedFinalize).not.toHaveBeenCalled(); });
it("hands a failed exchange to the caller without an unhandled rejection", async () => { mockedConsumePending.mockReturnValue(stash); mockedFinalize.mockRejectedValue(new LoginError("unknown state provided")); const auth = createAuth("http://127.0.0.1:1", seeded);
const resumed = resumeSignIn(auth, "/");
await expect(resumed?.done).rejects.toThrow(/unknown state/); expect(auth.state.kind).toBe("logged-out"); });});