import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { mkdtempSync, statSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; describe('auth/anthropic', () => { const originalHome = process.env.HOME; const originalEnvKey = process.env.ANTHROPIC_API_KEY; const originalEnvToken = process.env.ANTHROPIC_AUTH_TOKEN; let homeDir: string; beforeEach(() => { homeDir = mkdtempSync(join(tmpdir(), 'flynn-auth-anthropic-')); process.env.HOME = homeDir; delete process.env.ANTHROPIC_API_KEY; delete process.env.ANTHROPIC_AUTH_TOKEN; vi.resetModules(); }); afterEach(() => { process.env.HOME = originalHome; if (originalEnvKey) { process.env.ANTHROPIC_API_KEY = originalEnvKey; } else { delete process.env.ANTHROPIC_API_KEY; } if (originalEnvToken) { process.env.ANTHROPIC_AUTH_TOKEN = originalEnvToken; } else { delete process.env.ANTHROPIC_AUTH_TOKEN; } }); it('stores, loads, and clears Anthropic API key', async () => { const mod = await import('./anthropic.js'); expect(mod.loadStoredAnthropicAuth()).toBeNull(); mod.storeAnthropicAuth('sk-test'); expect(mod.loadStoredAnthropicAuth()?.api_key).toBe('sk-test'); const authFile = join(homeDir, '.config/flynn/auth.json'); const mode = statSync(authFile).mode & 0o777; expect(mode).toBe(0o600); mod.clearAnthropicAuth(); expect(mod.loadStoredAnthropicAuth()).toBeNull(); }); it('stores, loads, and clears Anthropic auth token', async () => { const mod = await import('./anthropic.js'); expect(mod.loadStoredAnthropicAuthToken()).toBeNull(); mod.storeAnthropicAuthToken('tok-test'); expect(mod.loadStoredAnthropicAuthToken()).toBe('tok-test'); const authFile = join(homeDir, '.config/flynn/auth.json'); const mode = statSync(authFile).mode & 0o777; expect(mode).toBe(0o600); mod.clearAnthropicAuthToken(); expect(mod.loadStoredAnthropicAuthToken()).toBeNull(); }); it('getAnthropicApiKey prefers environment variable', async () => { process.env.ANTHROPIC_API_KEY = 'sk-env'; const mod = await import('./anthropic.js'); expect(mod.getAnthropicApiKey()).toBe('sk-env'); }); it('getAnthropicAuthToken prefers environment variable', async () => { process.env.ANTHROPIC_AUTH_TOKEN = 'tok-env'; const mod = await import('./anthropic.js'); expect(mod.getAnthropicAuthToken()).toBe('tok-env'); }); }); describe('generateCodeVerifier and generateCodeChallenge', () => { it('generateCodeVerifier returns a 43-char base64url string with no padding', async () => { vi.resetModules(); const { generateCodeVerifier } = await import('./anthropic.js'); const v = generateCodeVerifier(); expect(v).toMatch(/^[A-Za-z0-9_-]+$/); expect(v.length).toBe(43); expect(v).not.toContain('='); }); it('generateCodeChallenge returns correct SHA256 base64url of the verifier', async () => { vi.resetModules(); const { generateCodeVerifier, generateCodeChallenge } = await import('./anthropic.js'); const { createHash } = await import('crypto'); const verifier = generateCodeVerifier(); const challenge = generateCodeChallenge(verifier); const expected = createHash('sha256').update(verifier).digest('base64url'); expect(challenge).toBe(expected); expect(challenge).not.toContain('='); expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/); }); it('generateCodeVerifier produces unique values', async () => { vi.resetModules(); const { generateCodeVerifier } = await import('./anthropic.js'); const a = generateCodeVerifier(); const b = generateCodeVerifier(); expect(a).not.toBe(b); }); }); describe('startCallbackServer', () => { it('resolves with code and state when browser redirects to /callback', async () => { vi.resetModules(); const { startCallbackServer } = await import('./anthropic.js'); const { port, waitForCode } = await startCallbackServer(5000); const res = await fetch(`http://127.0.0.1:${port}/callback?code=test-code&state=test-state`); expect(res.status).toBe(200); const text = await res.text(); expect(text.toLowerCase()).toContain('close this tab'); const { code, state } = await waitForCode; expect(code).toBe('test-code'); expect(state).toBe('test-state'); }); it('rejects waitForCode on timeout', async () => { vi.resetModules(); const { startCallbackServer } = await import('./anthropic.js'); const { waitForCode } = await startCallbackServer(50); await expect(waitForCode).rejects.toThrow(/timed out/i); }); it('startCallbackServer rejects when signal is aborted', async () => { vi.resetModules(); const { startCallbackServer } = await import('./anthropic.js'); const controller = new AbortController(); const { waitForCode } = await startCallbackServer(5000, controller.signal); controller.abort(); await expect(waitForCode).rejects.toThrow(/cancelled/); }); it('returns 404 for non-callback paths', async () => { vi.resetModules(); const { startCallbackServer } = await import('./anthropic.js'); const { port, waitForCode } = await startCallbackServer(5000); const res = await fetch(`http://127.0.0.1:${port}/other`); expect(res.status).toBe(404); // Clean up: hit the real callback to resolve await fetch(`http://127.0.0.1:${port}/callback?code=c&state=s`); await waitForCode; }); }); describe('loginAnthropicOAuth and exchangeCodeForToken', () => { const originalHome = process.env.HOME; let homeDir: string; let mockFetch: ReturnType; beforeEach(() => { homeDir = mkdtempSync(join(tmpdir(), 'flynn-auth-oauth-')); process.env.HOME = homeDir; mockFetch = vi.fn(); vi.stubGlobal('fetch', mockFetch); vi.resetModules(); }); afterEach(() => { process.env.HOME = originalHome; vi.unstubAllGlobals(); }); it('loginAnthropicOAuth builds correct auth URL and exchanges code for token', async () => { const fakeToken = 'tok-from-oauth'; mockFetch.mockResolvedValue({ ok: true, json: async () => ({ access_token: fakeToken }), }); const { loginAnthropicOAuth } = await import('./anthropic.js'); let capturedAuthUrl = ''; let resolveCb!: (v: { code: string; state: string }) => void; // Custom _startServer that gives us control over waitForCode const mockStartServer = async (_timeoutMs: number) => { return { port: 9999, waitForCode: new Promise<{ code: string; state: string }>((res) => { resolveCb = res; }), }; }; // Start the flow — onOpen fires after _startServer resolves and URL is built const flowPromise = loginAnthropicOAuth((url) => { capturedAuthUrl = url; // Extract state from the URL and resolve waitForCode with matching state const parsedUrl = new URL(url); const state = parsedUrl.searchParams.get('state')!; resolveCb({ code: 'auth-code-123', state }); }, undefined, mockStartServer); const token = await flowPromise; // Auth URL assertions const authUrl = new URL(capturedAuthUrl); expect(authUrl.hostname).toBe('claude.ai'); expect(authUrl.searchParams.get('client_id')).toBe('9d1c250a-e61b-44d9-88ed-5944d1962f5e'); expect(authUrl.searchParams.get('code_challenge_method')).toBe('S256'); expect(authUrl.searchParams.get('response_type')).toBe('code'); expect(authUrl.searchParams.get('scope')).toContain('user:inference'); expect(authUrl.searchParams.get('state')).toBeTruthy(); // Token stored and returned expect(token).toBe(fakeToken); // Verify token exchange fetch expect(mockFetch).toHaveBeenCalledWith( 'https://console.anthropic.com/v1/oauth/token', expect.objectContaining({ method: 'POST' }), ); }); it('loginAnthropicOAuth throws on state mismatch', async () => { const { loginAnthropicOAuth } = await import('./anthropic.js'); const mockStartServer = async (_timeoutMs: number) => ({ port: 9999, waitForCode: Promise.resolve({ code: 'code', state: 'DEFINITELY-WRONG-STATE' }), }); await expect( loginAnthropicOAuth(() => undefined, undefined, mockStartServer), ).rejects.toThrow('state mismatch'); }); it('exchangeCodeForToken throws subscription message on 403', async () => { mockFetch.mockResolvedValue({ ok: false, status: 403, text: async () => 'Forbidden', }); const { exchangeCodeForToken } = await import('./anthropic.js'); await expect( exchangeCodeForToken('code', 'verifier', 'http://127.0.0.1:1234/callback'), ).rejects.toThrow(/Pro\/Max subscription/); }); it('exchangeCodeForToken throws with status on 500', async () => { mockFetch.mockResolvedValue({ ok: false, status: 500, text: async () => 'Server error', }); const { exchangeCodeForToken } = await import('./anthropic.js'); await expect( exchangeCodeForToken('code', 'verifier', 'http://127.0.0.1:1234/callback'), ).rejects.toThrow(/500/); }); it('exchangeCodeForToken throws when no access_token in response', async () => { mockFetch.mockResolvedValue({ ok: true, json: async () => ({ something_else: 'x' }), }); const { exchangeCodeForToken } = await import('./anthropic.js'); await expect( exchangeCodeForToken('code', 'verifier', 'http://127.0.0.1:1234/callback'), ).rejects.toThrow(/no access_token/); }); });