expand session test coverage, fix abort-test contamination
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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> = {},
|
||||||
|
): 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<Response>
|
||||||
|
> = {},
|
||||||
|
) {
|
||||||
|
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<Response> => {
|
||||||
|
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<typeof makeMockFetch>
|
||||||
|
|
||||||
|
/** 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]))
|
||||||
|
}
|
||||||
@@ -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'
|
import {act, render} from '@testing-library/react-native'
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -86,8 +86,20 @@ function renderProvider(): SessionApiContext {
|
|||||||
* for an account the app is no longer tracking.
|
* for an account the app is no longer tracking.
|
||||||
*/
|
*/
|
||||||
describe('superseded session tasks dispose their bundle', () => {
|
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 () => {
|
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
|
let resolveLogin!: (value: unknown) => void
|
||||||
mockLogin.mockReturnValueOnce(
|
mockLogin.mockReturnValueOnce(
|
||||||
new Promise(resolve => {
|
new Promise(resolve => {
|
||||||
@@ -110,7 +122,7 @@ describe('superseded session tasks dispose their bundle', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('disposes the bundle of an aborted createAccount', async () => {
|
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
|
let resolveCreate!: (value: unknown) => void
|
||||||
mockCreateAccount.mockReturnValueOnce(
|
mockCreateAccount.mockReturnValueOnce(
|
||||||
new Promise(resolve => {
|
new Promise(resolve => {
|
||||||
|
|||||||
@@ -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<unknown>>()
|
||||||
|
const mockResume = jest.fn<(...args: unknown[]) => Promise<unknown>>()
|
||||||
|
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<object>('../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> = {}): 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<Harness> {
|
||||||
|
let api!: SessionApiContext
|
||||||
|
let session!: ReturnType<typeof useSession>
|
||||||
|
function Probe() {
|
||||||
|
api = useSessionApi()
|
||||||
|
session = useSession()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
render(
|
||||||
|
<Provider>
|
||||||
|
<Probe />
|
||||||
|
</Provider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -12,6 +12,31 @@ jest.mock('#/state/events', () => ({
|
|||||||
emitNetworkLost: jest.fn(),
|
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
|
* `prefetchAgeAssuranceServerData` is a genuine prep await in each factory
|
||||||
* (moderation config is synchronous, so the AA prefetch is where the factory
|
* (moderation config is synchronous, so the AA prefetch is where the factory
|
||||||
@@ -71,32 +96,29 @@ import {
|
|||||||
buildBundle,
|
buildBundle,
|
||||||
createSessionBundleFromStoredAccount,
|
createSessionBundleFromStoredAccount,
|
||||||
disposeBundle,
|
disposeBundle,
|
||||||
|
finishPreparation,
|
||||||
makeSessionHooks,
|
makeSessionHooks,
|
||||||
registerBundleKillSwitch,
|
registerBundleKillSwitch,
|
||||||
sessionAccountToSessionData,
|
sessionAccountToSessionData,
|
||||||
type SessionBundle,
|
type SessionBundle,
|
||||||
sessionDataToSessionAccount,
|
sessionDataToSessionAccount,
|
||||||
} from '../session-core'
|
} from '../session-core'
|
||||||
|
import {
|
||||||
const DID = 'did:plc:example123'
|
asFetch,
|
||||||
const HANDLE = 'alice.test'
|
DID,
|
||||||
const SERVICE = 'https://bsky.social'
|
HANDLE,
|
||||||
const PDS_URL = 'https://shimeji.us-east.host.bsky.network'
|
makeAccount,
|
||||||
|
makeDidDoc,
|
||||||
|
makeMockFetch,
|
||||||
|
PDS_HOST as PDS_URL,
|
||||||
|
SERVICE,
|
||||||
|
} from './mock-fetch'
|
||||||
|
|
||||||
function synthDidDoc(
|
function synthDidDoc(
|
||||||
did: string,
|
did: string,
|
||||||
pdsUrl: string,
|
pdsUrl: string,
|
||||||
): NonNullable<SessionData['didDoc']> {
|
): NonNullable<SessionData['didDoc']> {
|
||||||
return {
|
return makeDidDoc(pdsUrl, did)
|
||||||
id: did,
|
|
||||||
service: [
|
|
||||||
{
|
|
||||||
id: '#atproto_pds',
|
|
||||||
type: 'AtprotoPersonalDataServer',
|
|
||||||
serviceEndpoint: pdsUrl,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeSessionData(overrides: Partial<SessionData> = {}): SessionData {
|
function makeSessionData(overrides: Partial<SessionData> = {}): SessionData {
|
||||||
@@ -342,25 +364,6 @@ describe('sessionAccountToSessionData', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function makeAccount(overrides: Partial<SessionAccount> = {}): 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', () => {
|
describe('createSessionBundleFromStoredAccount', () => {
|
||||||
it('builds a bridge agent over one session', () => {
|
it('builds a bridge agent over one session', () => {
|
||||||
const result = createSessionBundleFromStoredAccount(
|
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<Response>
|
|
||||||
> = {},
|
|
||||||
) {
|
|
||||||
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<Response> => {
|
|
||||||
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 makeMockFetch>): typeof fetch {
|
|
||||||
return mock as unknown as typeof fetch
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('makeSessionHooks arm-latch + event mapping', () => {
|
describe('makeSessionHooks arm-latch + event mapping', () => {
|
||||||
/*
|
/*
|
||||||
* The hooks read neither `this` (the PasswordSession) nor their data
|
* 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
|
* A refresh through a bundle's armed hooks: the app has no `refreshSession` api
|
||||||
* `PasswordSession.refresh()`: on success the armed hooks dispatch exactly one
|
* of its own, so every rotation the reducer sees originates here, in
|
||||||
* 'update' event and the returned snapshot reflects the refreshed data;
|
* `PasswordSession.refresh()`. On success the armed hooks dispatch exactly one
|
||||||
* rejections propagate. We exercise the auth-core mechanics that the callback
|
* 'update' event and the post-refresh snapshot carries the rotated tokens;
|
||||||
* relies on (a full provider render is out of scope for a unit test).
|
* 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 () => {
|
it('refresh() resolves updated data and the armed hooks dispatch exactly one update', async () => {
|
||||||
const fetchMock = makeMockFetch()
|
const fetchMock = makeMockFetch()
|
||||||
const onSessionChange =
|
const onSessionChange =
|
||||||
@@ -808,7 +758,7 @@ describe('refreshSession semantics', () => {
|
|||||||
expect(onSessionChange).toHaveBeenCalledTimes(1)
|
expect(onSessionChange).toHaveBeenCalledTimes(1)
|
||||||
expect(onSessionChange.mock.calls[0][2]).toBe('update')
|
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(
|
const snapshot = sessionDataToSessionAccount(
|
||||||
session.session,
|
session.session,
|
||||||
session.session.service,
|
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<void>,
|
||||||
|
) {
|
||||||
|
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 resume/login factories must snapshot the returned account after
|
||||||
* the prep awaits, not before. A 401 during prep triggers PasswordSession's
|
* 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.
|
* captured fetch is what PasswordSession's auto-refresh routes through.
|
||||||
*/
|
*/
|
||||||
describe('factory account snapshot after preparation', () => {
|
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<void>,
|
|
||||||
) {
|
|
||||||
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(() => {
|
beforeEach(() => {
|
||||||
mockConfigureModerationForAccount.mockReset()
|
mockConfigureModerationForAccount.mockReset()
|
||||||
mockPrefetchAgeAssuranceServerData.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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -22,6 +22,18 @@ jest.mock('#/lib/notifications/notifications', () => ({
|
|||||||
return Promise.resolve()
|
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.
|
// Reuse a bundle within each test: session events are scoped by bundle identity.
|
||||||
function makeBundle(service: string) {
|
function makeBundle(service: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user