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! :)
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});