diff --git a/src/state/session/__tests__/mock-fetch.ts b/src/state/session/__tests__/mock-fetch.ts new file mode 100644 index 0000000000..a107d8dc3c --- /dev/null +++ b/src/state/session/__tests__/mock-fetch.ts @@ -0,0 +1,126 @@ +import {jest} from '@jest/globals' + +import {type SessionAccount} from '../types' + +/* + * Shared fixtures for the suites that drive a real `PasswordSession` over a + * stubbed network. Not a suite itself - the filename deliberately avoids the + * `-test` suffix so jest does not collect it. + */ + +export const DID = 'did:plc:example123' +export const HANDLE = 'alice.test' +export const SERVICE = 'https://bsky.social' +/** A PDS host an account may be pinned to by its stored `pdsUrl`. */ +export const PDS_HOST = 'https://shimeji.us-east.host.bsky.network' +/** A different PDS host, delivered by the didDoc a refresh returns. */ +export const DIDDOC_PDS_HOST = 'https://morel.us-west.host.bsky.network' + +export function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: {'content-type': 'application/json'}, + }) +} + +/** A minimal valid DID document whose only service entry is a PDS. */ +export function makeDidDoc(pdsUrl: string, did: string = DID) { + return { + id: did, + service: [ + { + id: '#atproto_pds', + type: 'AtprotoPersonalDataServer', + serviceEndpoint: pdsUrl, + }, + ], + } +} + +export function makeAccount( + overrides: Partial = {}, +): SessionAccount { + return { + service: SERVICE, + did: DID, + handle: HANDLE, + email: 'alice@example.com', + emailConfirmed: true, + emailAuthFactor: false, + refreshJwt: 'refresh-jwt', + accessJwt: 'access-jwt', + signupQueued: false, + active: true, + status: undefined, + pdsUrl: undefined, + isSelfHosted: false, + ...overrides, + } +} + +/** + * Build a mock `fetch` that returns canned XRPC responses keyed by the last + * path segment (nsid). `refreshSession` returns fresh tokens; `getSession` + * echoes the account; anything else returns an empty 200. + * + * The refresh response carries both `emailConfirmed` and a `didDoc` so the + * library has no reason to make a `getSession` follow-up call, which keeps the + * recorded request list assertable. Its didDoc points at + * {@link DIDDOC_PDS_HOST}, a different host from {@link PDS_HOST}, so PDS + * re-routing after a refresh is observable. + */ +export function makeMockFetch( + overrides: Record< + string, + (url: string, init: RequestInit) => Response | Promise + > = {}, +) { + return jest.fn( + /* + * PasswordSession calls fetch with a URL object (new URL(path, service)); + * asFetch() below widens the mock to the full fetch signature it expects. + */ + async (input: URL | string, init: RequestInit = {}): Promise => { + const url = input instanceof URL ? input.href : input + const nsid = url.split('/xrpc/')[1]?.split('?')[0] + const handler = nsid ? overrides[nsid] : undefined + if (handler) { + return handler(url, init) + } + if (nsid === 'com.atproto.server.refreshSession') { + return json({ + accessJwt: 'access-jwt-2', + refreshJwt: 'refresh-jwt-2', + handle: HANDLE, + did: DID, + email: 'alice@example.com', + emailConfirmed: true, + didDoc: makeDidDoc(DIDDOC_PDS_HOST), + active: true, + }) + } + if (nsid === 'com.atproto.server.getSession') { + return json({ + did: DID, + handle: HANDLE, + email: 'alice@example.com', + emailConfirmed: true, + active: true, + }) + } + return json({}) + }, + ) +} + +export type MockFetch = ReturnType + +/** Cast a jest fetch mock to the `fetch` type PasswordSession options expect. */ +export function asFetch(mock: MockFetch): typeof fetch { + return mock as unknown as typeof fetch +} + +/** The URLs a mock fetch was called with, in order. */ +export function urlsOf(mock: MockFetch): string[] { + return mock.mock.calls.map(c => (c[0] instanceof URL ? c[0].href : c[0])) +} diff --git a/src/state/session/__tests__/provider-abort-test.tsx b/src/state/session/__tests__/provider-abort-test.tsx index 0bf5c08f21..fdc1585981 100644 --- a/src/state/session/__tests__/provider-abort-test.tsx +++ b/src/state/session/__tests__/provider-abort-test.tsx @@ -1,4 +1,4 @@ -import {describe, expect, it, jest} from '@jest/globals' +import {beforeEach, describe, expect, it, jest} from '@jest/globals' import {act, render} from '@testing-library/react-native' /* @@ -86,8 +86,20 @@ function renderProvider(): SessionApiContext { * for an account the app is no longer tracking. */ describe('superseded session tasks dispose their bundle', () => { + /* + * Without this, a recorded call from an earlier test satisfies a later + * assertion. The tagged bundles below are the other half of that guard: two + * `{}` literals are structurally equal, so `toHaveBeenCalledWith` could not + * tell one test's bundle from the other's even within a cleared mock. + */ + beforeEach(() => { + mockLogin.mockReset() + mockCreateAccount.mockReset() + mockDisposeBundle.mockReset() + }) + it('disposes the bundle of an aborted login', async () => { - const bundle = {} as never + const bundle = {tag: 'login-bundle'} as never let resolveLogin!: (value: unknown) => void mockLogin.mockReturnValueOnce( new Promise(resolve => { @@ -110,7 +122,7 @@ describe('superseded session tasks dispose their bundle', () => { }) it('disposes the bundle of an aborted createAccount', async () => { - const bundle = {} as never + const bundle = {tag: 'create-account-bundle'} as never let resolveCreate!: (value: unknown) => void mockCreateAccount.mockReturnValueOnce( new Promise(resolve => { diff --git a/src/state/session/__tests__/provider-session-events-test.tsx b/src/state/session/__tests__/provider-session-events-test.tsx new file mode 100644 index 0000000000..778f83d508 --- /dev/null +++ b/src/state/session/__tests__/provider-session-events-test.tsx @@ -0,0 +1,476 @@ +import {type SessionData} from '@atproto/lex-password-session' +import {beforeEach, describe, expect, it, jest} from '@jest/globals' +import {act, render} from '@testing-library/react-native' + +import {type Schema} from '#/state/persisted/schema' +import {type SessionAccount} from '../types' + +/* + * The provider pulls the whole app shell in through `#/state/util` and the + * account factories. These mocks cut the tree back to the session lifecycle + * itself, which is all these tests drive. They mirror provider-abort-test.tsx, + * plus a stateful `#/state/persisted` (this suite drives cross-tab updates and + * the expiry rescue's fresh persisted read) and an observable + * `emitSessionDropped`. + */ +const mockPersisted: {session: Schema['session']; latest: Schema['session']} = { + session: {accounts: [], currentAccount: undefined}, + latest: {accounts: [], currentAccount: undefined}, +} +/* + * Every registered listener is kept, not just the newest. The provider's + * subscription effect re-runs on every state change, so a callback captured + * before a dispatch is exactly the stale-closure case the shouldActivate guard + * exists to catch. + */ +const mockPersistedListeners: ((value: Schema['session']) => void)[] = [] +jest.mock('#/state/persisted', () => { + const { + defaults, + }: typeof import('#/state/persisted/schema') = require('#/state/persisted/schema') + return { + defaults, + get: (key: string) => + key === 'session' + ? mockPersisted.session + : defaults[key as keyof typeof defaults], + readLatest: (key: string) => + key === 'session' + ? mockPersisted.latest + : defaults[key as keyof typeof defaults], + write: () => Promise.resolve(), + onUpdate: (_key: string, cb: (value: Schema['session']) => void) => { + mockPersistedListeners.push(cb) + return () => {} + }, + } +}) +jest.mock('#/state/util', () => ({useCloseAllActiveElements: () => () => {}})) +jest.mock('#/components/dialogs/Context', () => ({ + useGlobalDialogsControlContext: () => ({signinDialogControl: {open() {}}}), +})) +jest.mock('#/analytics', () => ({ + AnalyticsContext: ({children}: {children: React.ReactNode}) => children, + useAnalyticsBase: () => ({metric() {}, logger: {debug() {}, error() {}}}), + utils: {accountToSessionMetadata: () => ({}), useMeta: () => undefined}, +})) +jest.mock('#/state/shell/onboarding', () => ({ + useOnboardingDispatch: () => () => {}, +})) +jest.mock('#/ageAssurance/data', () => ({ + clearAgeAssuranceServerDataForAll: () => {}, + clearAgeAssuranceServerDataForDid: () => {}, +})) +jest.mock('#/lib/persisted-query-storage', () => ({ + clearPersistedQueryStorage: () => Promise.resolve(), +})) +jest.mock('#/lib/notifications/notifications', () => ({ + unregisterPushToken: () => Promise.resolve(), +})) +jest.mock('jwt-decode', () => ({jwtDecode: () => ({})})) + +const mockEmitSessionDropped = jest.fn() +jest.mock('#/state/events', () => ({ + emitSessionDropped: () => mockEmitSessionDropped(), + emitNetworkConfirmed: () => {}, + emitNetworkLost: () => {}, +})) + +/* + * The factories are stubbed so a test controls exactly what each one returns + * and when. `createSessionBundleFromStoredAccount` is stubbed faithfully rather + * than replaced by a constant: it must still consult `shouldActivate` and + * decline to hand back a bundle when the guard rejects, because that decision + * is what these tests observe. Its disposal of a rejected bundle is pinned by + * session-core-test; here we only assert what the provider does with the + * result. + */ +const mockLogin = jest.fn<(...args: unknown[]) => Promise>() +const mockResume = jest.fn<(...args: unknown[]) => Promise>() +const mockDisposeBundle = jest.fn() +type Rebuild = { + account: SessionAccount + shouldActivate: boolean + bundle: FakeBundle +} +const mockRebuilds: Rebuild[] = [] +const mockRebuild = jest.fn( + ( + account: SessionAccount, + _onSessionChange: unknown, + shouldActivate: ( + bundle: unknown, + account: SessionAccount, + ) => boolean = () => true, + ) => { + const bundle = makeBundle(account) + const activated = shouldActivate(bundle, account) + mockRebuilds.push({account, shouldActivate: activated, bundle}) + return activated ? {bundle, account} : undefined + }, +) +jest.mock('../session-core', () => ({ + ...jest.requireActual('../session-core'), + createSessionBundleAndLogin: (...args: unknown[]) => mockLogin(...args), + createSessionBundleAndResume: (...args: unknown[]) => mockResume(...args), + createSessionBundleFromStoredAccount: (...args: unknown[]) => + // @ts-expect-error the stub's arity is checked by its own signature + mockRebuild(...args), + disposeBundle: (bundle: unknown) => mockDisposeBundle(bundle), +})) +jest.mock('../create-account', () => ({ + createSessionBundleAndCreateAccount: () => new Promise(() => {}), +})) + +import {Provider, useSession, useSessionApi} from '#/state/session' +import { + type OnSessionChange, + type SessionBundle, +} from '#/state/session/session-core' +import {type SessionApiContext} from '#/state/session/types' + +const DID = 'did:plc:example123' +const SERVICE = 'https://bsky.social/' + +function makeAccount(overrides: Partial = {}): SessionAccount { + return { + service: SERVICE, + did: DID, + handle: 'alice.test', + email: 'alice@example.com', + emailConfirmed: true, + emailAuthFactor: false, + refreshJwt: 'refresh-jwt-1', + accessJwt: 'access-jwt-1', + signupQueued: false, + active: true, + status: undefined, + pdsUrl: undefined, + isSelfHosted: false, + ...overrides, + } +} + +/* + * The provider only ever reads `bundle.agent` (for context) and + * `bundle.session.destroyed` / `bundle.session.session` (for the cross-tab + * token comparison), and otherwise treats a bundle as an opaque identity. A + * literal with those fields is enough, and keeps a real PasswordSession - with + * its network and refresh machinery - out of a suite about provider dispatch. + */ +type FakeBundle = { + session: {destroyed: boolean; session: SessionData} + agent: object + service: URL +} + +function makeBundle(account: SessionAccount): FakeBundle { + return { + session: { + destroyed: false, + session: { + accessJwt: account.accessJwt ?? '', + refreshJwt: account.refreshJwt ?? '', + /* SessionData types these as branded strings; the values are fixtures */ + handle: account.handle as `${string}.${string}`, + did: account.did as `did:${string}:${string}`, + active: true, + service: account.service, + }, + }, + agent: {}, + service: new URL(account.service), + } +} + +type Harness = { + api: SessionApiContext + /** The provider's own onSessionChange, as handed to a session factory. */ + onSessionChange: OnSessionChange + currentAccount: () => SessionAccount | undefined + hasSession: () => boolean +} + +/** + * Render the provider, log an account in through the stubbed login factory, and + * hand back the api plus the `onSessionChange` the factory received. Firing + * that callback is how a test synthesizes a session event from a live bundle. + */ +async function renderLoggedIn( + account: SessionAccount, + bundle: FakeBundle, +): Promise { + let api!: SessionApiContext + let session!: ReturnType + function Probe() { + api = useSessionApi() + session = useSession() + return null + } + render( + + + , + ) + + let captured!: OnSessionChange + mockLogin.mockImplementationOnce((...args: unknown[]) => { + captured = args[1] as OnSessionChange + return Promise.resolve({bundle, account}) + }) + await act(async () => { + await api.login({} as never, 'LoginForm') + }) + + return { + api, + onSessionChange: captured, + currentAccount: () => session.currentAccount, + hasSession: () => session.hasSession, + } +} + +/** The dying payload PasswordSession threads through its `onDeleted` hook. */ +function dyingData(refreshJwt: string): SessionData { + return { + accessJwt: 'dead-access-jwt', + refreshJwt, + handle: 'alice.test', + did: DID, + active: true, + service: SERVICE, + } +} + +beforeEach(() => { + mockPersisted.session = {accounts: [], currentAccount: undefined} + mockPersisted.latest = {accounts: [], currentAccount: undefined} + mockPersistedListeners.length = 0 + mockRebuilds.length = 0 + mockLogin.mockReset() + mockResume.mockReset() + mockRebuild.mockClear() + mockDisposeBundle.mockReset() + mockEmitSessionDropped.mockReset() +}) + +/* + * A stale tab can expire a refresh token that another tab has already rotated + * past. Logging every tab out on that event is the known-worst failure in this + * subsystem, so the provider first looks for a newer token generation and + * rebuilds onto it, only falling through to logout when there is nothing left + * to try. + */ +describe('expiry rescue', () => { + it('rebuilds onto a fresher persisted generation instead of logging out', async () => { + const account = makeAccount() + const bundle = makeBundle(account) + const {onSessionChange, hasSession, currentAccount} = await renderLoggedIn( + account, + bundle, + ) + + /* another tab already rotated to generation 2 and wrote it to storage */ + const fresher = makeAccount({ + accessJwt: 'access-jwt-2', + refreshJwt: 'refresh-jwt-2', + }) + mockPersisted.latest = {accounts: [fresher], currentAccount: fresher} + + act(() => { + onSessionChange( + bundle as unknown as SessionBundle, + DID, + 'expired', + dyingData('refresh-jwt-1'), + ) + }) + + /* the rescue rebuilt onto the fresher generation ... */ + expect(mockRebuilds.length).toBe(1) + expect(mockRebuilds[0].account.refreshJwt).toBe('refresh-jwt-2') + /* ... and adopted it, without ever reporting the session as dropped */ + expect(hasSession()).toBe(true) + expect(currentAccount()?.refreshJwt).toBe('refresh-jwt-2') + expect(mockEmitSessionDropped).not.toHaveBeenCalled() + }) + + it('drops the session and logs out when there is no fresher generation', async () => { + const account = makeAccount() + const bundle = makeBundle(account) + const {onSessionChange, hasSession, currentAccount} = await renderLoggedIn( + account, + bundle, + ) + + /* storage agrees the dying token is the newest one anybody has */ + mockPersisted.latest = {accounts: [account], currentAccount: account} + + act(() => { + onSessionChange( + bundle as unknown as SessionBundle, + DID, + 'expired', + dyingData('refresh-jwt-1'), + ) + }) + + expect(mockRebuilds.length).toBe(0) + expect(mockEmitSessionDropped).toHaveBeenCalledTimes(1) + expect(hasSession()).toBe(false) + /* the reducer cleared the dead credentials rather than keeping them */ + expect(currentAccount()).toBe(undefined) + }) + + it('does not retry a generation that already failed', async () => { + const account = makeAccount() + const bundle = makeBundle(account) + const {onSessionChange, hasSession} = await renderLoggedIn(account, bundle) + + const gen2 = makeAccount({ + accessJwt: 'access-jwt-2', + refreshJwt: 'refresh-jwt-2', + }) + mockPersisted.latest = {accounts: [gen2], currentAccount: gen2} + + /* generation 1 dies and is rescued onto generation 2 */ + act(() => { + onSessionChange( + bundle as unknown as SessionBundle, + DID, + 'expired', + dyingData('refresh-jwt-1'), + ) + }) + expect(mockRebuilds.length).toBe(1) + const rescued = mockRebuilds[0].bundle + + /* + * Generation 2 dies too, and a stale tab has meanwhile written generation 1 + * back to storage. It differs from the dying token, so only the record of + * its earlier failure can reject it. + */ + mockPersisted.latest = {accounts: [account], currentAccount: account} + act(() => { + onSessionChange( + rescued as unknown as SessionBundle, + DID, + 'expired', + dyingData('refresh-jwt-2'), + ) + }) + + expect(mockRebuilds.length).toBe(1) + expect(mockEmitSessionDropped).toHaveBeenCalledTimes(1) + expect(hasSession()).toBe(false) + }) +}) + +/** Deliver a cross-tab `persisted` update to the provider's newest listener. */ +function emitSynced(session: Schema['session']) { + mockPersistedListeners[mockPersistedListeners.length - 1](session) +} + +/* + * A `PasswordSession` cannot be patched in place, so adopting tokens another + * tab refreshed means rebuilding the bundle. Doing that for every broadcast + * would churn the agent (and the React tree under it) constantly, so the + * provider rebuilds only when the tokens actually moved, and guards the swap + * against the store having advanced underneath it. + */ +describe('cross-tab sync', () => { + it('short-circuits an update carrying the tokens the live session already has', async () => { + const account = makeAccount() + const bundle = makeBundle(account) + const {hasSession} = await renderLoggedIn(account, bundle) + + act(() => { + emitSynced({accounts: [account], currentAccount: account}) + }) + + /* identical tokens: nothing to adopt, so no rebuild */ + expect(mockRebuilds.length).toBe(0) + expect(hasSession()).toBe(true) + }) + + it('rebuilds onto tokens another tab rotated', async () => { + const account = makeAccount() + const bundle = makeBundle(account) + const {currentAccount} = await renderLoggedIn(account, bundle) + + const rotated = makeAccount({ + accessJwt: 'access-jwt-2', + refreshJwt: 'refresh-jwt-2', + }) + act(() => { + emitSynced({accounts: [rotated], currentAccount: rotated}) + }) + + expect(mockRebuilds.length).toBe(1) + expect(mockRebuilds[0].account.refreshJwt).toBe('refresh-jwt-2') + expect(currentAccount()?.refreshJwt).toBe('refresh-jwt-2') + }) + + it('declines to activate a rebuild once the store has moved past the bundle it was built for', async () => { + const account = makeAccount() + const bundle = makeBundle(account) + await renderLoggedIn(account, bundle) + + const gen2 = makeAccount({ + accessJwt: 'access-jwt-2', + refreshJwt: 'refresh-jwt-2', + }) + const gen3 = makeAccount({ + accessJwt: 'access-jwt-3', + refreshJwt: 'refresh-jwt-3', + }) + + /* + * Two broadcasts land back to back inside one act(), so React does not + * commit (and the effect does not re-subscribe) between them: the second + * runs the listener registered while the ORIGINAL bundle was current, even + * though the store has since advanced to the generation-2 rebuild. + */ + const listener = mockPersistedListeners[mockPersistedListeners.length - 1] + act(() => { + listener({accounts: [gen2], currentAccount: gen2}) + listener({accounts: [gen3], currentAccount: gen3}) + }) + + expect(mockRebuilds.length).toBe(2) + expect(mockRebuilds[0].shouldActivate).toBe(true) + /* the stale closure's bundle is no longer current, so the swap is refused */ + expect(mockRebuilds[1].account.refreshJwt).toBe('refresh-jwt-3') + expect(mockRebuilds[1].shouldActivate).toBe(false) + }) + + it('cancels pending work when another tab logs the account out', async () => { + const account = makeAccount() + const bundle = makeBundle(account) + const {api} = await renderLoggedIn(account, bundle) + + /* a resume is in flight and will resolve only after the cross-tab logout */ + const resumedBundle = makeBundle(account) + let finishResume!: (value: unknown) => void + mockResume.mockReturnValueOnce( + new Promise(resolve => { + finishResume = resolve + }), + ) + const pending = api.resumeSession(account) + + const loggedOut = makeAccount({accessJwt: undefined, refreshJwt: undefined}) + act(() => { + emitSynced({accounts: [loggedOut], currentAccount: loggedOut}) + }) + + await act(async () => { + finishResume({bundle: resumedBundle, account}) + await pending + }) + + /* the superseded resume disposed its bundle rather than signing back in */ + expect(mockDisposeBundle).toHaveBeenCalledWith(resumedBundle) + expect(mockRebuilds.length).toBe(0) + }) +}) diff --git a/src/state/session/__tests__/session-core-test.ts b/src/state/session/__tests__/session-core-test.ts index 7551f95d2a..bb1896d389 100644 --- a/src/state/session/__tests__/session-core-test.ts +++ b/src/state/session/__tests__/session-core-test.ts @@ -12,6 +12,31 @@ jest.mock('#/state/events', () => ({ emitNetworkLost: jest.fn(), })) +/* + * `makeSessionHooks` reports a throwing `onSessionChange` through the logger + * rather than letting it escape into PasswordSession's session promise. Stub + * the module so that report is assertable (and so nothing reaches the real + * transports). + */ +const mockLoggerError = jest.fn() +jest.mock('#/logger', () => { + const noopLogger = { + error: (...args: unknown[]) => mockLoggerError(...args), + warn: () => {}, + info: () => {}, + log: () => {}, + debug: () => {}, + } + return { + logger: noopLogger, + Logger: { + create: () => noopLogger, + Context: new Proxy({}, {get: (_t, key) => String(key)}), + Level: {}, + }, + } +}) + /* * `prefetchAgeAssuranceServerData` is a genuine prep await in each factory * (moderation config is synchronous, so the AA prefetch is where the factory @@ -71,32 +96,29 @@ import { buildBundle, createSessionBundleFromStoredAccount, disposeBundle, + finishPreparation, makeSessionHooks, registerBundleKillSwitch, sessionAccountToSessionData, type SessionBundle, sessionDataToSessionAccount, } from '../session-core' - -const DID = 'did:plc:example123' -const HANDLE = 'alice.test' -const SERVICE = 'https://bsky.social' -const PDS_URL = 'https://shimeji.us-east.host.bsky.network' +import { + asFetch, + DID, + HANDLE, + makeAccount, + makeDidDoc, + makeMockFetch, + PDS_HOST as PDS_URL, + SERVICE, +} from './mock-fetch' function synthDidDoc( did: string, pdsUrl: string, ): NonNullable { - return { - id: did, - service: [ - { - id: '#atproto_pds', - type: 'AtprotoPersonalDataServer', - serviceEndpoint: pdsUrl, - }, - ], - } + return makeDidDoc(pdsUrl, did) } function makeSessionData(overrides: Partial = {}): SessionData { @@ -342,25 +364,6 @@ describe('sessionAccountToSessionData', () => { }) }) -function makeAccount(overrides: Partial = {}): SessionAccount { - return { - service: SERVICE, - did: DID, - handle: HANDLE, - email: 'alice@example.com', - emailConfirmed: true, - emailAuthFactor: false, - refreshJwt: 'refresh-jwt', - accessJwt: 'access-jwt', - signupQueued: false, - active: true, - status: undefined, - pdsUrl: undefined, - isSelfHosted: false, - ...overrides, - } -} - describe('createSessionBundleFromStoredAccount', () => { it('builds a bridge agent over one session', () => { const result = createSessionBundleFromStoredAccount( @@ -400,59 +403,6 @@ describe('createSessionBundleFromStoredAccount', () => { }) }) -/** - * Build a mock `fetch` that returns canned XRPC responses keyed by the last - * path segment (nsid). `refreshSession` returns fresh tokens; `getSession` - * echoes the account; anything else returns an empty 200. - */ -function makeMockFetch( - overrides: Record< - string, - (url: string, init: RequestInit) => Response | Promise - > = {}, -) { - const json = (body: unknown, status = 200) => - new Response(JSON.stringify(body), { - status, - headers: {'content-type': 'application/json'}, - }) - const fetchMock = jest.fn( - async (input: URL | string, init: RequestInit = {}): Promise => { - const url = input instanceof URL ? input.href : input - const nsid = url.split('/xrpc/')[1]?.split('?')[0] - const handler = nsid ? overrides[nsid] : undefined - if (handler) { - return handler(url, init) - } - if (nsid === 'com.atproto.server.refreshSession') { - return json({ - accessJwt: 'access-jwt-2', - refreshJwt: 'refresh-jwt-2', - handle: HANDLE, - did: DID, - active: true, - }) - } - if (nsid === 'com.atproto.server.getSession') { - return json({ - did: DID, - handle: HANDLE, - email: 'alice@example.com', - emailConfirmed: true, - active: true, - }) - } - return json({}) - }, - ) - return fetchMock -} - -/** Cast a jest fetch mock to the `fetch` type PasswordSession options expect. */ -function asFetch(mock: ReturnType): typeof fetch { - return mock as unknown as typeof fetch -} - describe('makeSessionHooks arm-latch + event mapping', () => { /* * The hooks read neither `this` (the PasswordSession) nor their data @@ -770,13 +720,13 @@ describe('PasswordSession lifecycle over mocked fetch', () => { }) /* - * `useSessionApi().refreshSession()` is a thin wrapper over - * `PasswordSession.refresh()`: on success the armed hooks dispatch exactly one - * 'update' event and the returned snapshot reflects the refreshed data; - * rejections propagate. We exercise the auth-core mechanics that the callback - * relies on (a full provider render is out of scope for a unit test). + * A refresh through a bundle's armed hooks: the app has no `refreshSession` api + * of its own, so every rotation the reducer sees originates here, in + * `PasswordSession.refresh()`. On success the armed hooks dispatch exactly one + * 'update' event and the post-refresh snapshot carries the rotated tokens; + * rejections propagate to the caller. */ -describe('refreshSession semantics', () => { +describe('PasswordSession.refresh through armed hooks', () => { it('refresh() resolves updated data and the armed hooks dispatch exactly one update', async () => { const fetchMock = makeMockFetch() const onSessionChange = @@ -808,7 +758,7 @@ describe('refreshSession semantics', () => { expect(onSessionChange).toHaveBeenCalledTimes(1) expect(onSessionChange.mock.calls[0][2]).toBe('update') - /* the callback's return value is the post-refresh SessionAccount snapshot */ + /* the post-refresh SessionAccount snapshot carries the rotated tokens */ const snapshot = sessionDataToSessionAccount( session.session, session.session.service, @@ -833,6 +783,30 @@ describe('refreshSession semantics', () => { }) }) +/** + * Load a fresh factory graph whose network leaf captures `fetch`. + * + * `session-core`'s network leaf reads `globalThis.fetch` at module load, and + * that captured fetch is what `PasswordSession`'s auto-refresh routes through, + * so the module has to be re-required after the override is in place. + */ +async function withFreshFactory( + fetch: typeof globalThis.fetch, + run: (core: typeof import('../session-core')) => Promise, +) { + const realFetch = globalThis.fetch + globalThis.fetch = fetch + try { + await jest.isolateModulesAsync(async () => { + const core = + require('../session-core') as typeof import('../session-core') + await run(core) + }) + } finally { + globalThis.fetch = realFetch + } +} + /* * The resume/login factories must snapshot the returned account after * the prep awaits, not before. A 401 during prep triggers PasswordSession's @@ -849,24 +823,6 @@ describe('refreshSession semantics', () => { * captured fetch is what PasswordSession's auto-refresh routes through. */ describe('factory account snapshot after preparation', () => { - /** Load a fresh factory graph whose network leaf captures `fetch`. */ - async function withFreshFactory( - fetch: typeof globalThis.fetch, - run: (core: typeof import('../session-core')) => Promise, - ) { - const realFetch = globalThis.fetch - globalThis.fetch = fetch - try { - await jest.isolateModulesAsync(async () => { - const core = - require('../session-core') as typeof import('../session-core') - await run(core) - }) - } finally { - globalThis.fetch = realFetch - } - } - beforeEach(() => { mockConfigureModerationForAccount.mockReset() mockPrefetchAgeAssuranceServerData.mockReset() @@ -925,3 +881,147 @@ describe('factory account snapshot after preparation', () => { }) }) }) + +/* + * Preparation is the window between building the bundle and arming its hooks. + * The session is live but its events are swallowed, so a session that dies in + * here would otherwise leave the app holding a bundle that looks signed in and + * can only make unauthenticated requests, with nothing left to log it out. + * Both failure modes must therefore dispose the bundle and reject. + */ +describe('a session destroyed or rejected during preparation', () => { + beforeEach(() => { + mockConfigureModerationForAccount.mockReset() + mockPrefetchAgeAssuranceServerData.mockReset() + }) + + it('resume: rejects with the revoked-during-prep error and disposes the bundle', async () => { + /* + * A revoked refresh token: the session's own refresh during prep gets a + * declared invalid-token error, which destroys it. `logout()` is the only + * way to drive a session to `destroyed` from outside, and it takes the same + * `deleteSession` -> onDeleted -> destroyed path the real 401 rescue does. + */ + let capturedAgent: BskyAppAgent | undefined + mockConfigureModerationForAccount.mockImplementationOnce( + (agent: unknown) => { + capturedAgent = agent as BskyAppAgent + }, + ) + mockPrefetchAgeAssuranceServerData.mockImplementationOnce(async () => { + await capturedAgent!.logout() + }) + const fetchMock = makeMockFetch() + + await withFreshFactory(asFetch(fetchMock), async core => { + /* + * The clean error, not PasswordSession's opaque `Logged out` getter + * throw, which is what a naive `snapshot()` on a destroyed session would + * surface. + */ + await expect( + core.createSessionBundleAndResume( + makeAccount({accessJwt: 'valid-access-jwt'}), + jest.fn(), + ), + ).rejects.toThrow('Session was revoked while it was being prepared') + + /* the bundle the caller never received reads as logged out */ + expect(capturedAgent!.session).toBe(undefined) + }) + }) + + it('resume: a prep rejection propagates and disposes the bundle', async () => { + let capturedAgent: BskyAppAgent | undefined + mockConfigureModerationForAccount.mockImplementationOnce( + (agent: unknown) => { + capturedAgent = agent as BskyAppAgent + }, + ) + mockPrefetchAgeAssuranceServerData.mockImplementationOnce(() => + Promise.reject(new Error('prefetch blew up')), + ) + const fetchMock = makeMockFetch() + + await withFreshFactory(asFetch(fetchMock), async core => { + await expect( + core.createSessionBundleAndResume( + makeAccount({accessJwt: 'valid-access-jwt'}), + jest.fn(), + ), + ).rejects.toThrow('prefetch blew up') + + /* the still-live session was disposed rather than left refreshing */ + expect(capturedAgent!.session).toBe(undefined) + }) + }) + + it('finishPreparation disposes and rethrows without running the snapshot', async () => { + const hooks = makeSessionHooks( + jest.fn(), + () => bundle, + () => DID, + ) + const session = new PasswordSession( + sessionAccountToSessionData(makeAccount()), + {...hooks, fetch: asFetch(makeMockFetch())}, + ) + const bundle = buildBundle(session) + registerBundleKillSwitch(bundle, hooks.kill) + const snapshot = jest.fn(() => 'never') + + await expect( + finishPreparation(bundle, Promise.reject(new Error('nope')), snapshot), + ).rejects.toThrow('nope') + + expect(snapshot).not.toHaveBeenCalled() + expect(bundle.agent.session).toBe(undefined) + }) +}) + +/* + * A hook must never throw: PasswordSession awaits its hooks inside the + * assignment to its internal session promise, so an escaping throw would leave + * that promise permanently rejected - every later request fails, while the + * session is never marked destroyed, so disposeBundle cannot even see that the + * bundle is dead. + */ +describe('a throwing onSessionChange does not brick the session', () => { + beforeEach(() => { + mockLoggerError.mockClear() + }) + + it('reports through logger.error and leaves the session usable', async () => { + const fetchMock = makeMockFetch() + const onSessionChange = jest.fn(() => { + throw new Error('reducer side effect exploded') + }) + let bundle!: SessionBundle + const hooks = makeSessionHooks( + onSessionChange, + () => bundle, + () => DID, + ) + const session = new PasswordSession( + sessionAccountToSessionData(makeAccount()), + {...hooks, fetch: asFetch(fetchMock)}, + ) + bundle = buildBundle(session) + hooks.arm() + + await session.refresh() + + expect(onSessionChange).toHaveBeenCalledTimes(1) + expect(mockLoggerError).toHaveBeenCalledTimes(1) + expect(mockLoggerError.mock.calls[0][1]).toEqual({ + message: "session: onSessionChange threw for a 'update' event", + }) + + /* the session still committed the rotation, and can still refresh again */ + expect(session.session.accessJwt).toBe('access-jwt-2') + await expect(session.refresh()).resolves.toBeDefined() + await expect( + session.fetchHandler('/xrpc/app.bsky.actor.getProfile', {}), + ).resolves.toBeDefined() + }) +}) diff --git a/src/state/session/__tests__/session-test.ts b/src/state/session/__tests__/session-test.ts index c084e3dc08..9cb2db6a59 100644 --- a/src/state/session/__tests__/session-test.ts +++ b/src/state/session/__tests__/session-test.ts @@ -22,6 +22,18 @@ jest.mock('#/lib/notifications/notifications', () => ({ return Promise.resolve() }, })) +/* + * The logout and account-removal reducer cases fire a push-token side effect + * whose first step, `createTemporaryAgentsAndResume`, builds real `AtpAgent`s + * and resumes them over the real network. Under jest that request outlives the + * suite: it rejects after teardown, and the resulting `logger.error` reaches + * for `nanoid` in an environment that no longer has it, failing whichever suite + * happens to be running at that moment. Stubbing the module keeps the side + * effect synchronous and offline. + */ +jest.mock('../util', () => ({ + createTemporaryAgentsAndResume: () => Promise.resolve([]), +})) // Reuse a bundle within each test: session events are scoped by bundle identity. function makeBundle(service: string) {