import type { AtpSessionData } from '@atproto/api'; import { AtpAgent } from '@atproto/api'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { TangledApiClient } from '../../src/lib/api-client.js'; import * as sessionModule from '../../src/lib/session.js'; import { KeychainAccessError } from '../../src/lib/session.js'; import * as pdsResolver from '../../src/utils/pds-resolver.js'; import { mockSessionData, mockSessionData2, mockSessionMetadata } from '../helpers/mock-data.js'; // Singleton mock agent — always the same object regardless of how many times // new AtpAgent() is called. This keeps test references valid even when login() // and resumeSession() recreate the agent with a different service URL. // Must be defined with vi.hoisted because vi.mock factories are hoisted to the // top of the file and run before regular variable declarations. const { mockAgent } = vi.hoisted(() => { const agent = { service: { toString: () => 'https://bsky.social' }, get session() { return agent._session; }, _session: undefined as AtpSessionData | undefined, // Mirrors the real CredentialSession: the agent exposes sessionManager, and // assigning sessionManager.session installs a session without any network // call. Capturing persistSession lets resumeSession's mock simulate the // library firing the callback on a token rotation. persistSession: undefined as ((event: string, session?: AtpSessionData) => void) | undefined, sessionManager: { get session() { return agent._session; }, set session(value: AtpSessionData | undefined) { agent._session = value; }, }, login: vi.fn(), resumeSession: vi.fn(), }; return { mockAgent: agent }; }); vi.mock('@atproto/api', () => ({ AtpAgent: vi.fn( (opts?: { persistSession?: (event: string, session?: AtpSessionData) => void }) => { mockAgent.persistSession = opts?.persistSession; return mockAgent; } ), })); vi.mock('../../src/lib/session.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, saveSession: vi.fn(), loadSession: vi.fn(), deleteSession: vi.fn(), saveCurrentSessionMetadata: vi.fn(), getCurrentSessionMetadata: vi.fn(), clearCurrentSessionMetadata: vi.fn(), }; }); vi.mock('../../src/utils/pds-resolver.js', () => ({ resolvePdsFromIdentifier: vi.fn().mockResolvedValue('https://bsky.social'), })); describe('TangledApiClient', () => { let client: TangledApiClient; beforeEach(() => { vi.clearAllMocks(); mockAgent._session = undefined; vi.mocked(mockAgent.login).mockImplementation(async () => { mockAgent._session = mockSessionData; return { success: true, data: mockSessionData }; }); // The real resumeSession forces a refresh that rotates the tokens and fires // persistSession('update'). Simulate that so the save path is exercised. vi.mocked(mockAgent.resumeSession).mockImplementation(async (session: AtpSessionData) => { mockAgent._session = session; mockAgent.persistSession?.('update', session); }); vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(null); vi.mocked(sessionModule.loadSession).mockResolvedValue(null); vi.mocked(pdsResolver.resolvePdsFromIdentifier).mockResolvedValue('https://bsky.social'); client = new TangledApiClient(); }); describe('login', () => { it('should login successfully and save session', async () => { const result = await client.login('user.bsky.social', 'password'); expect(result).toEqual(mockSessionData); expect(vi.mocked(sessionModule.saveSession)).toHaveBeenCalledWith(mockSessionData); expect(vi.mocked(sessionModule.saveCurrentSessionMetadata)).toHaveBeenCalledWith({ handle: mockSessionData.handle, did: mockSessionData.did, pds: 'https://bsky.social', lastUsed: expect.any(String), }); }); it('should resolve PDS from identifier and construct agent with that URL', async () => { vi.mocked(pdsResolver.resolvePdsFromIdentifier).mockResolvedValue('https://tngl.sh'); await client.login('user.tngl.sh', 'password'); expect(pdsResolver.resolvePdsFromIdentifier).toHaveBeenCalledWith('user.tngl.sh'); expect(AtpAgent).toHaveBeenCalledWith({ service: 'https://tngl.sh' }); expect(vi.mocked(sessionModule.saveCurrentSessionMetadata)).toHaveBeenCalledWith( expect.objectContaining({ pds: 'https://tngl.sh' }) ); }); it('should support DID identifiers', async () => { vi.mocked(pdsResolver.resolvePdsFromIdentifier).mockResolvedValue('https://tngl.sh'); await client.login('did:plc:abc123', 'password'); expect(pdsResolver.resolvePdsFromIdentifier).toHaveBeenCalledWith('did:plc:abc123'); }); it('should throw error on login failure', async () => { vi.mocked(mockAgent.login).mockResolvedValueOnce({ success: false, headers: {}, data: undefined, } as never); await expect(client.login('user.bsky.social', 'wrong')).rejects.toThrow( 'Login failed: No session data received' ); }); it('should throw error when PDS resolution fails', async () => { vi.mocked(pdsResolver.resolvePdsFromIdentifier).mockRejectedValue( new Error('Could not resolve handle "bad.handle"') ); await expect(client.login('bad.handle', 'password')).rejects.toThrow( 'Could not resolve handle "bad.handle"' ); }); }); describe('logout', () => { it('should logout and clear session', async () => { vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); await client.logout(); expect(vi.mocked(sessionModule.deleteSession)).toHaveBeenCalledWith(mockSessionMetadata.did); expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).toHaveBeenCalled(); }); it('should throw error if no active session', async () => { vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(null); await expect(client.logout()).rejects.toThrow('No active session found'); }); }); describe('resumeSession', () => { it('should resume session from stored data', async () => { vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); vi.mocked(sessionModule.loadSession).mockResolvedValue(mockSessionData); const resumed = await client.resumeSession(); expect(resumed).toBe(true); expect(vi.mocked(sessionModule.loadSession)).toHaveBeenCalledWith(mockSessionMetadata.did); expect(vi.mocked(sessionModule.saveCurrentSessionMetadata)).toHaveBeenCalledWith({ ...mockSessionMetadata, lastUsed: expect.any(String), }); }); it('should construct agent with stored PDS URL when resuming', async () => { const tnglMetadata = { ...mockSessionMetadata, pds: 'https://tngl.sh' }; vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(tnglMetadata); vi.mocked(sessionModule.loadSession).mockResolvedValue(mockSessionData); await client.resumeSession(); expect(AtpAgent).toHaveBeenCalledWith( expect.objectContaining({ service: 'https://tngl.sh' }) ); }); it('should return false if no metadata exists', async () => { vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(null); const resumed = await client.resumeSession(); expect(resumed).toBe(false); }); it('should return false and cleanup if session data is missing', async () => { vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); vi.mocked(sessionModule.loadSession).mockResolvedValue(null); const resumed = await client.resumeSession(); expect(resumed).toBe(false); expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).toHaveBeenCalled(); }); it('should durably re-save the session the agent holds after resuming', async () => { vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); vi.mocked(sessionModule.loadSession).mockResolvedValue(mockSessionData); // Simulate the agent refreshing and rotating its tokens during resume: it // ends up holding a different session than the stale one loaded from the // keychain and fires persistSession with it. The rotated session must be // written back, awaited, so the next CLI invocation can refresh again // instead of presenting a consumed token. vi.mocked(mockAgent.resumeSession).mockImplementationOnce(async () => { mockAgent._session = mockSessionData2; mockAgent.persistSession?.('update', mockSessionData2); }); await client.resumeSession(); expect(vi.mocked(sessionModule.saveSession)).toHaveBeenCalledWith(mockSessionData2); }); it('should reuse a still-valid access token without forcing a refresh', async () => { // A real JWT whose exp is comfortably in the future. resumeSession should // install it directly and never call the agent's resumeSession (which // would rotate the single-use refresh token) or write to the keychain. const futureExp = Math.floor(Date.now() / 1000) + 3600; const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url'); const body = Buffer.from(JSON.stringify({ exp: futureExp })).toString('base64url'); const liveSession: AtpSessionData = { ...mockSessionData, accessJwt: `${header}.${body}.sig`, }; vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); vi.mocked(sessionModule.loadSession).mockResolvedValue(liveSession); const resumed = await client.resumeSession(); expect(resumed).toBe(true); expect(vi.mocked(mockAgent.resumeSession)).not.toHaveBeenCalled(); expect(vi.mocked(sessionModule.saveSession)).not.toHaveBeenCalled(); expect(mockAgent.session).toEqual(liveSession); // Last-used metadata is still refreshed so status reflects recent activity. expect(vi.mocked(sessionModule.saveCurrentSessionMetadata)).toHaveBeenCalled(); }); it('should return false without clearing metadata on transient resume error', async () => { vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); vi.mocked(sessionModule.loadSession).mockResolvedValue(mockSessionData); vi.mocked(mockAgent.resumeSession).mockRejectedValueOnce(new Error('Resume failed')); const resumed = await client.resumeSession(); expect(resumed).toBe(false); expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).not.toHaveBeenCalled(); }); it('should rethrow KeychainAccessError without clearing metadata', async () => { vi.mocked(sessionModule.getCurrentSessionMetadata).mockRejectedValueOnce( new KeychainAccessError('Cannot access keychain: locked') ); await expect(client.resumeSession()).rejects.toThrow(KeychainAccessError); expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).not.toHaveBeenCalled(); }); }); describe('isAuthenticated', () => { it('should return false when not authenticated', () => { expect(client.isAuthenticated()).toBe(false); }); }); describe('getAgent', () => { it('should return the AtpAgent instance', () => { const agent = client.getAgent(); expect(agent).toBeDefined(); }); }); describe('getSession', () => { it('should return undefined when not authenticated', () => { expect(client.getSession()).toBeUndefined(); }); }); });