Something went wrong. Try again.
WIP: A simple cli for daily tangled use cases and AI integration. This is for my personal use right now, but happy if others get mileage fro
Something went wrong. Try again.
12 kB · 292 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293import 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<typeof import('../../src/lib/session.js')>(); 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(); }); });});