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 from it! :)
at main 146 lines 5.2 kB view raw
1import { execSync } from 'node:child_process'; 2import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 3import type { TangledApiClient } from '../../src/lib/api-client.js'; 4import { KeychainAccessError } from '../../src/lib/session.js'; 5import { ensureAuthenticated, requireAuth } from '../../src/utils/auth-helpers.js'; 6 7vi.mock('node:child_process', () => ({ 8 execSync: vi.fn(), 9})); 10 11// Mock API client factory 12const createMockClient = ( 13 authenticated: boolean, 14 session: { did: string; handle: string } | null 15): TangledApiClient => { 16 return { 17 isAuthenticated: vi.fn(() => authenticated), 18 getSession: vi.fn(() => session), 19 } as unknown as TangledApiClient; 20}; 21 22describe('requireAuth', () => { 23 it('should return session when authenticated', async () => { 24 const mockSession = { did: 'did:plc:test123', handle: 'test.bsky.social' }; 25 const mockClient = createMockClient(true, mockSession); 26 27 const result = await requireAuth(mockClient); 28 29 expect(result).toEqual(mockSession); 30 }); 31 32 it('should throw error when not authenticated', async () => { 33 const mockClient = createMockClient(false, null); 34 35 await expect(requireAuth(mockClient)).rejects.toThrow( 36 'Must be authenticated. Run "tangled auth login" first.' 37 ); 38 }); 39 40 it('should throw error when authenticated but no session', async () => { 41 const mockClient = createMockClient(true, null); 42 43 await expect(requireAuth(mockClient)).rejects.toThrow('No active session found'); 44 }); 45}); 46 47describe('ensureAuthenticated', () => { 48 // biome-ignore lint/suspicious/noExplicitAny: spy instance types vary by platform signature 49 let mockExit: any; 50 // biome-ignore lint/suspicious/noExplicitAny: spy instance types vary by platform signature 51 let mockConsoleError: any; 52 53 beforeEach(() => { 54 mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { 55 throw new Error('process.exit called'); 56 }); 57 mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); 58 vi.mocked(execSync).mockReset(); 59 }); 60 61 afterEach(() => { 62 mockExit.mockRestore(); 63 mockConsoleError.mockRestore(); 64 }); 65 66 it('should return normally when resumeSession succeeds', async () => { 67 const mockClient = { 68 resumeSession: vi.fn().mockResolvedValue(true), 69 } as unknown as TangledApiClient; 70 71 await expect(ensureAuthenticated(mockClient)).resolves.toBeUndefined(); 72 expect(mockExit).not.toHaveBeenCalled(); 73 }); 74 75 it('should exit with error when not authenticated', async () => { 76 const mockClient = { 77 resumeSession: vi.fn().mockResolvedValue(false), 78 } as unknown as TangledApiClient; 79 80 await expect(ensureAuthenticated(mockClient)).rejects.toThrow('process.exit called'); 81 expect(mockConsoleError).toHaveBeenCalledWith( 82 '✗ Not authenticated. Run "tangled auth login" first.' 83 ); 84 expect(mockExit).toHaveBeenCalledWith(1); 85 }); 86 87 it.skipIf(process.platform !== 'darwin')( 88 'should unlock keychain and retry when KeychainAccessError is thrown', 89 async () => { 90 const mockClient = { 91 resumeSession: vi 92 .fn() 93 .mockRejectedValueOnce(new KeychainAccessError('locked')) 94 .mockResolvedValueOnce(true), 95 } as unknown as TangledApiClient; 96 97 vi.mocked(execSync).mockReturnValue(Buffer.from('')); 98 99 await expect(ensureAuthenticated(mockClient)).resolves.toBeUndefined(); 100 expect(execSync).toHaveBeenCalledWith('security unlock-keychain', { stdio: 'inherit' }); 101 expect(mockExit).not.toHaveBeenCalled(); 102 } 103 ); 104 105 it('should exit with keychain error when unlock fails', async () => { 106 const mockClient = { 107 resumeSession: vi.fn().mockRejectedValue(new KeychainAccessError('locked')), 108 } as unknown as TangledApiClient; 109 110 vi.mocked(execSync).mockImplementation(() => { 111 throw new Error('unlock failed'); 112 }); 113 114 await expect(ensureAuthenticated(mockClient)).rejects.toThrow('process.exit called'); 115 expect(mockConsoleError).toHaveBeenCalledWith( 116 '✗ Cannot access keychain. Please unlock your Mac keychain and try again.' 117 ); 118 expect(mockExit).toHaveBeenCalledWith(1); 119 }); 120 121 it('should exit with keychain error when unlock succeeds but retry fails', async () => { 122 const mockClient = { 123 resumeSession: vi 124 .fn() 125 .mockRejectedValueOnce(new KeychainAccessError('locked')) 126 .mockRejectedValueOnce(new KeychainAccessError('still locked')), 127 } as unknown as TangledApiClient; 128 129 vi.mocked(execSync).mockReturnValue(Buffer.from('')); 130 131 await expect(ensureAuthenticated(mockClient)).rejects.toThrow('process.exit called'); 132 expect(mockConsoleError).toHaveBeenCalledWith( 133 '✗ Cannot access keychain. Please unlock your Mac keychain and try again.' 134 ); 135 expect(mockExit).toHaveBeenCalledWith(1); 136 }); 137 138 it('should rethrow unexpected errors', async () => { 139 const mockClient = { 140 resumeSession: vi.fn().mockRejectedValue(new Error('unexpected network error')), 141 } as unknown as TangledApiClient; 142 143 await expect(ensureAuthenticated(mockClient)).rejects.toThrow('unexpected network error'); 144 expect(mockExit).not.toHaveBeenCalled(); 145 }); 146});