bf8b6bc0c7
- PasswordSession (@atproto/lex-password-session) is now the single auth
core; SessionAgent bridges it to the base Agent from @atproto/api so
the ~148 useAgent() consumer files keep working unchanged
- SessionBundle carries {session, agent, accountClient, appviewClient};
useLexClient() now returns the authed appview client, new
useAppviewClient()/usePdsClient() hooks
- reducer: OpaqueSessionBundle, new replaced-current-bundle action for
network-free cross-tab same-did sync (PasswordSession is immutable, so
the bundle is rebuilt instead of mutated in place)
- moderation: AtpAgent.configure -> Client.configure + Agent.configure,
per-account labelers on both the bridge and the appview client
- push-token unregistration uses temporary PasswordSessions
- hard-tail migrations: SignupQueued refresh via resumeSession shim,
useBeginAgeAssurance scoped-token raw Client, useAccountEmailState
reads currentAccount
- PDS routing preserved on the no-network resume fast path by
synthesizing a minimal didDoc from the persisted pdsUrl
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
376 lines
13 KiB
TypeScript
376 lines
13 KiB
TypeScript
import {type AtpSessionEvent} from '@atproto/api'
|
|
import {
|
|
PasswordSession,
|
|
type PasswordSessionOptions,
|
|
type SessionData,
|
|
} from '@atproto/lex-password-session'
|
|
import {describe, expect, it, jest} from '@jest/globals'
|
|
|
|
jest.mock('#/state/events', () => ({
|
|
emitNetworkConfirmed: jest.fn(),
|
|
emitNetworkLost: jest.fn(),
|
|
}))
|
|
|
|
/*
|
|
* session-core imports the factory dependency graph (birthdate,
|
|
* restrictChatSettings, ageAssurance, moderation). Mock the heavy leaves so
|
|
* these tests do not pull in the native module chain (same approach as
|
|
* session-test.ts / session-core-test.ts).
|
|
*/
|
|
jest.mock('#/state/birthdate')
|
|
jest.mock('#/ageAssurance/data')
|
|
jest.mock('#/ageAssurance/state', () => ({
|
|
unsafeGetAndComputeAgeAssurance: () => ({state: {}, flags: {}}),
|
|
}))
|
|
jest.mock('#/state/queries/messages/restrictChatSettings', () => ({
|
|
restrictChatSettings: () => Promise.resolve(),
|
|
}))
|
|
jest.mock('jwt-decode', () => ({
|
|
jwtDecode() {
|
|
return {scope: 'com.atproto.access'}
|
|
},
|
|
}))
|
|
|
|
import {
|
|
makeSessionHooks,
|
|
sessionAccountToSessionData,
|
|
SessionAgent,
|
|
} from '../session-core'
|
|
import {type SessionAccount} from '../types'
|
|
|
|
const DID = 'did:plc:example123'
|
|
const HANDLE = 'alice.test'
|
|
const SERVICE = 'https://bsky.social'
|
|
const PDS_URL = 'https://shimeji.us-east.host.bsky.network'
|
|
|
|
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.
|
|
*/
|
|
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(
|
|
/*
|
|
* 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,
|
|
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('SessionAgent getters', () => {
|
|
it('reads live SessionData through .session', () => {
|
|
const session = new PasswordSession(
|
|
sessionAccountToSessionData(makeAccount()),
|
|
{fetch: asFetch(makeMockFetch())},
|
|
)
|
|
const agent = new SessionAgent(session)
|
|
expect(agent.session?.did).toBe(DID)
|
|
expect(agent.session?.handle).toBe(HANDLE)
|
|
expect(agent.did).toBe(DID)
|
|
})
|
|
|
|
it('derives serviceUrl from the session service', () => {
|
|
const session = new PasswordSession(
|
|
sessionAccountToSessionData(makeAccount()),
|
|
{fetch: asFetch(makeMockFetch())},
|
|
)
|
|
const agent = new SessionAgent(session)
|
|
expect(agent.serviceUrl.toString()).toBe('https://bsky.social/')
|
|
})
|
|
|
|
it('derives pdsUrl/dispatchUrl from a synthetic didDoc', () => {
|
|
const session = new PasswordSession(
|
|
sessionAccountToSessionData(makeAccount({pdsUrl: `${PDS_URL}/`})),
|
|
{fetch: asFetch(makeMockFetch())},
|
|
)
|
|
const agent = new SessionAgent(session)
|
|
expect(agent.pdsUrl?.toString()).toBe(`${PDS_URL}/`)
|
|
expect(agent.dispatchUrl.toString()).toBe(`${PDS_URL}/`)
|
|
})
|
|
|
|
it('dispatchUrl falls back to serviceUrl when there is no PDS', () => {
|
|
const session = new PasswordSession(
|
|
sessionAccountToSessionData(makeAccount()),
|
|
{fetch: asFetch(makeMockFetch())},
|
|
)
|
|
const agent = new SessionAgent(session)
|
|
expect(agent.pdsUrl).toBe(undefined)
|
|
expect(agent.dispatchUrl.toString()).toBe('https://bsky.social/')
|
|
})
|
|
|
|
it('public agent exposes undefined session and public serviceUrl', () => {
|
|
const agent = new SessionAgent(null)
|
|
expect(agent.session).toBe(undefined)
|
|
expect(agent.did).toBe(undefined)
|
|
expect(agent.serviceUrl.toString()).toBe('https://public.api.bsky.app/')
|
|
})
|
|
})
|
|
|
|
describe('SessionAgent.resumeSession', () => {
|
|
it('calls session.refresh() and returns a success result', async () => {
|
|
const fetchMock = makeMockFetch()
|
|
const session = new PasswordSession(
|
|
sessionAccountToSessionData(makeAccount()),
|
|
{fetch: asFetch(fetchMock)},
|
|
)
|
|
const agent = new SessionAgent(session)
|
|
const res = await agent.resumeSession()
|
|
expect(res.success).toBe(true)
|
|
expect(res.data.accessJwt).toBe('access-jwt-2')
|
|
/* one refreshSession call */
|
|
const calls = fetchMock.mock.calls.map(c =>
|
|
c[0] instanceof URL ? c[0].href : c[0],
|
|
)
|
|
expect(
|
|
calls.some(u => u.includes('com.atproto.server.refreshSession')),
|
|
).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('SessionAgent destroyed session', () => {
|
|
it('did/session getters do not throw after logout', async () => {
|
|
const session = new PasswordSession(
|
|
sessionAccountToSessionData(makeAccount()),
|
|
{fetch: asFetch(makeMockFetch())},
|
|
)
|
|
const agent = new SessionAgent(session)
|
|
await session.logout()
|
|
expect(session.destroyed).toBe(true)
|
|
/* PasswordSession.did throws when destroyed; SessionAgent must not */
|
|
expect(() => agent.did).not.toThrow()
|
|
expect(agent.did).toBe(undefined)
|
|
expect(agent.session).toBe(undefined)
|
|
})
|
|
})
|
|
|
|
describe('SessionAgent namespace routing', () => {
|
|
it('routes a call through the session fetchHandler with proxy + labeler headers', async () => {
|
|
const seen: {url: string; headers: Headers}[] = []
|
|
const fetchMock = makeMockFetch({
|
|
'app.bsky.actor.getProfile': (url, init) => {
|
|
seen.push({url, headers: new Headers(init.headers)})
|
|
return new Response(JSON.stringify({did: DID, handle: HANDLE}), {
|
|
status: 200,
|
|
headers: {'content-type': 'application/json'},
|
|
})
|
|
},
|
|
})
|
|
const session = new PasswordSession(
|
|
sessionAccountToSessionData(makeAccount({pdsUrl: `${PDS_URL}/`})),
|
|
{fetch: asFetch(fetchMock)},
|
|
)
|
|
const agent = new SessionAgent(session)
|
|
/* base Agent's configureProxy is what buildBundle applies in production */
|
|
agent.configureProxy('did:web:api.bsky.app#bsky_appview')
|
|
agent.configureLabelers(['did:plc:custom-labeler'])
|
|
|
|
/*
|
|
* The request headers (what we assert) are captured by the fetch mock
|
|
* before the base Agent parses the response body. Response-body lexicon
|
|
* validation can throw in the jest environment (a multiformats CID mock
|
|
* quirk unrelated to the header composition under test), so we ignore any
|
|
* parse error here.
|
|
*/
|
|
await agent.app.bsky.actor.getProfile({actor: HANDLE}).catch(() => {})
|
|
|
|
expect(seen.length).toBe(1)
|
|
expect(seen[0].headers.get('atproto-proxy')).toBe(
|
|
'did:web:api.bsky.app#bsky_appview',
|
|
)
|
|
const labelers = seen[0].headers.get('atproto-accept-labelers')
|
|
expect(labelers).toContain('did:plc:custom-labeler')
|
|
/* the session attaches the bearer token */
|
|
expect(seen[0].headers.get('authorization')).toBe('Bearer access-jwt')
|
|
})
|
|
})
|
|
|
|
describe('makeSessionHooks arm-latch + event mapping', () => {
|
|
/*
|
|
* The hooks read neither `this` (the PasswordSession) nor their data
|
|
* argument, so we invoke them with empty stand-ins cast to the declared
|
|
* parameter types. This keeps the test focused on the arm-latch + event
|
|
* mapping.
|
|
*/
|
|
const fakeSession = {} as PasswordSession
|
|
const fakeData = {} as SessionData
|
|
|
|
function setup() {
|
|
const onSessionChange =
|
|
jest.fn<
|
|
(agent: SessionAgent, did: string, event: AtpSessionEvent) => void
|
|
>()
|
|
const agent = new SessionAgent(null)
|
|
const hooks = makeSessionHooks(
|
|
onSessionChange,
|
|
() => agent,
|
|
() => DID,
|
|
)
|
|
return {onSessionChange, agent, hooks}
|
|
}
|
|
|
|
it('swallows events before arm()', () => {
|
|
const {onSessionChange, hooks} = setup()
|
|
void hooks.onUpdated?.call(fakeSession, fakeData)
|
|
expect(onSessionChange).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it("maps onUpdated -> 'update' after arm()", () => {
|
|
const {onSessionChange, hooks} = setup()
|
|
hooks.arm()
|
|
void hooks.onUpdated?.call(fakeSession, fakeData)
|
|
expect(onSessionChange).toHaveBeenCalledTimes(1)
|
|
expect(onSessionChange.mock.calls[0][2]).toBe('update')
|
|
})
|
|
|
|
it("maps onDeleted -> 'expired' after arm()", () => {
|
|
const {onSessionChange, hooks} = setup()
|
|
hooks.arm()
|
|
void hooks.onDeleted?.call(fakeSession, fakeData)
|
|
expect(onSessionChange.mock.calls[0][2]).toBe('expired')
|
|
})
|
|
|
|
it("maps onUpdateFailure -> 'network-error' after arm()", () => {
|
|
const {onSessionChange, hooks} = setup()
|
|
hooks.arm()
|
|
void hooks.onUpdateFailure?.call(
|
|
fakeSession,
|
|
fakeData,
|
|
{} as Parameters<NonNullable<typeof hooks.onUpdateFailure>>[1],
|
|
)
|
|
expect(onSessionChange.mock.calls[0][2]).toBe('network-error')
|
|
})
|
|
})
|
|
|
|
describe('PasswordSession lifecycle over mocked fetch', () => {
|
|
it('resume fast path: constructing does not hit the network', () => {
|
|
const fetchMock = makeMockFetch()
|
|
/* not expired -> new PasswordSession(...) with no refresh */
|
|
void new PasswordSession(sessionAccountToSessionData(makeAccount()), {
|
|
fetch: asFetch(fetchMock),
|
|
})
|
|
expect(fetchMock.mock.calls.length).toBe(0)
|
|
})
|
|
|
|
it('resume network path fires onUpdated with fresh tokens', async () => {
|
|
const fetchMock = makeMockFetch()
|
|
const onUpdated =
|
|
jest.fn<NonNullable<PasswordSessionOptions['onUpdated']>>()
|
|
const session = await PasswordSession.resume(
|
|
sessionAccountToSessionData(makeAccount()),
|
|
{fetch: asFetch(fetchMock), onUpdated},
|
|
)
|
|
expect(onUpdated).toHaveBeenCalledTimes(1)
|
|
expect(session.session.accessJwt).toBe('access-jwt-2')
|
|
})
|
|
|
|
it('onDeleted fires when refresh returns a declared invalid-token error', async () => {
|
|
const onDeleted =
|
|
jest.fn<NonNullable<PasswordSessionOptions['onDeleted']>>()
|
|
const onUpdated =
|
|
jest.fn<NonNullable<PasswordSessionOptions['onUpdated']>>()
|
|
const fetchMock = makeMockFetch({
|
|
'com.atproto.server.refreshSession': () =>
|
|
new Response(
|
|
JSON.stringify({error: 'ExpiredToken', message: 'Token expired'}),
|
|
{status: 400, headers: {'content-type': 'application/json'}},
|
|
),
|
|
})
|
|
const session = new PasswordSession(
|
|
sessionAccountToSessionData(makeAccount()),
|
|
{fetch: asFetch(fetchMock), onDeleted, onUpdated},
|
|
)
|
|
await expect(session.refresh()).rejects.toBeDefined()
|
|
expect(onDeleted).toHaveBeenCalledTimes(1)
|
|
expect(onUpdated).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('onUpdateFailure fires on a transient (500) refresh error, session preserved', async () => {
|
|
const onDeleted =
|
|
jest.fn<NonNullable<PasswordSessionOptions['onDeleted']>>()
|
|
const onUpdateFailure =
|
|
jest.fn<NonNullable<PasswordSessionOptions['onUpdateFailure']>>()
|
|
const fetchMock = makeMockFetch({
|
|
'com.atproto.server.refreshSession': () =>
|
|
new Response(JSON.stringify({error: 'InternalServerError'}), {
|
|
status: 500,
|
|
headers: {'content-type': 'application/json'},
|
|
}),
|
|
})
|
|
const session = new PasswordSession(
|
|
sessionAccountToSessionData(makeAccount()),
|
|
{
|
|
fetch: asFetch(fetchMock),
|
|
onDeleted,
|
|
onUpdateFailure,
|
|
},
|
|
)
|
|
await session.refresh()
|
|
expect(onUpdateFailure).toHaveBeenCalledTimes(1)
|
|
expect(onDeleted).not.toHaveBeenCalled()
|
|
/* session data is preserved (still the original tokens) */
|
|
expect(session.session.accessJwt).toBe('access-jwt')
|
|
})
|
|
})
|