| |
| |
| |
|
|
| import { describe, test, expect, beforeEach, afterEach } from 'bun:test' |
| import * as fs from 'fs/promises' |
| import * as path from 'path' |
| import * as os from 'os' |
| import { |
| HahaOAuthService, |
| type StoredOAuthTokens, |
| } from '../services/hahaOAuthService.js' |
|
|
| let tmpDir: string |
| let originalConfigDir: string | undefined |
| let service: HahaOAuthService |
|
|
| async function setup() { |
| tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'haha-oauth-test-')) |
| originalConfigDir = process.env.CLAUDE_CONFIG_DIR |
| process.env.CLAUDE_CONFIG_DIR = tmpDir |
| service = new HahaOAuthService() |
| } |
|
|
| async function teardown() { |
| if (originalConfigDir === undefined) { |
| delete process.env.CLAUDE_CONFIG_DIR |
| } else { |
| process.env.CLAUDE_CONFIG_DIR = originalConfigDir |
| } |
| await fs.rm(tmpDir, { recursive: true, force: true }) |
| } |
|
|
| describe('HahaOAuthService — file storage', () => { |
| beforeEach(setup) |
| afterEach(teardown) |
|
|
| test('loadTokens returns null when file does not exist', async () => { |
| expect(await service.loadTokens()).toBeNull() |
| }) |
|
|
| test('saveTokens writes file with 0600 permissions', async () => { |
| const tokens: StoredOAuthTokens = { |
| accessToken: 'sk-ant-oat01-xxx', |
| refreshToken: 'sk-ant-ort01-xxx', |
| expiresAt: Date.now() + 3600_000, |
| scopes: ['user:inference', 'user:profile'], |
| subscriptionType: 'max', |
| } |
| await service.saveTokens(tokens) |
|
|
| const oauthPath = path.join(tmpDir, 'cc-haha', 'oauth.json') |
| const stat = await fs.stat(oauthPath) |
| if (process.platform !== 'win32') { |
| expect(stat.mode & 0o777).toBe(0o600) |
| } |
|
|
| const loaded = await service.loadTokens() |
| expect(loaded).toEqual(tokens) |
| }) |
|
|
| test('deleteTokens removes file', async () => { |
| await service.saveTokens({ |
| accessToken: 'a', |
| refreshToken: null, |
| expiresAt: null, |
| scopes: [], |
| subscriptionType: null, |
| }) |
| await service.deleteTokens() |
| expect(await service.loadTokens()).toBeNull() |
| }) |
| }) |
|
|
| describe('HahaOAuthService — session management', () => { |
| beforeEach(setup) |
| afterEach(teardown) |
|
|
| test('startSession creates session with PKCE + state', () => { |
| const session = service.startSession({ serverPort: 54321 }) |
| expect(session.state).toMatch(/^[A-Za-z0-9_-]{43}$/) |
| expect(session.codeVerifier).toMatch(/^[A-Za-z0-9_-]{43}$/) |
| expect(session.authorizeUrl).toContain('code_challenge_method=S256') |
| expect(session.authorizeUrl).toContain(`state=${encodeURIComponent(session.state)}`) |
| expect(session.authorizeUrl).toContain('redirect_uri=') |
| expect(session.authorizeUrl).toContain(encodeURIComponent( |
| 'http://localhost:54321/callback', |
| )) |
| }) |
|
|
| test('getSession returns stored session by state', () => { |
| const session = service.startSession({ serverPort: 54321 }) |
| const found = service.getSession(session.state) |
| expect(found?.codeVerifier).toBe(session.codeVerifier) |
| }) |
|
|
| test('getSession returns null for unknown state', () => { |
| expect(service.getSession('unknown-state')).toBeNull() |
| }) |
|
|
| test('consumeSession removes session after fetch', () => { |
| const session = service.startSession({ serverPort: 54321 }) |
| expect(service.consumeSession(session.state)).not.toBeNull() |
| expect(service.getSession(session.state)).toBeNull() |
| }) |
|
|
| test('completeSession stores subscription type fetched from profile info', async () => { |
| const session = service.startSession({ serverPort: 54321 }) |
| ;(service as any).exchangeWithCustomCallback = async () => ({ |
| access_token: 'fresh-access-token', |
| refresh_token: 'fresh-refresh-token', |
| expires_in: 3600, |
| scope: 'user:inference', |
| }) |
| service.setFetchProfileFn(async () => ({ |
| subscriptionType: 'team', |
| })) |
|
|
| const tokens = await service.completeSession('authorization-code', session.state) |
|
|
| expect(tokens.subscriptionType).toBe('team') |
| expect((await service.loadTokens())?.subscriptionType).toBe('team') |
| }) |
| }) |
|
|
| describe('HahaOAuthService — ensureFreshAccessToken', () => { |
| beforeEach(setup) |
| afterEach(teardown) |
|
|
| test('returns null when no token file exists', async () => { |
| expect(await service.ensureFreshAccessToken()).toBeNull() |
| }) |
|
|
| test('returns token unchanged if not expired', async () => { |
| const tokens: StoredOAuthTokens = { |
| accessToken: 'still-valid', |
| refreshToken: 'refresh-xxx', |
| expiresAt: Date.now() + 30 * 60_000, |
| scopes: ['user:inference'], |
| subscriptionType: 'max', |
| } |
| await service.saveTokens(tokens) |
|
|
| expect(await service.ensureFreshAccessToken()).toBe('still-valid') |
| }) |
|
|
| test('refreshes token when expired (within 5-min buffer)', async () => { |
| const oldTokens: StoredOAuthTokens = { |
| accessToken: 'expired', |
| refreshToken: 'refresh-xxx', |
| expiresAt: Date.now() + 60_000, |
| scopes: ['user:inference'], |
| subscriptionType: 'max', |
| } |
| await service.saveTokens(oldTokens) |
|
|
| service.setRefreshFn(async () => ({ |
| accessToken: 'new-fresh-token', |
| refreshToken: 'new-refresh-xxx', |
| expiresAt: Date.now() + 3600_000, |
| scopes: ['user:inference'], |
| subscriptionType: 'max', |
| rateLimitTier: null, |
| })) |
|
|
| const fresh = await service.ensureFreshAccessToken() |
| expect(fresh).toBe('new-fresh-token') |
|
|
| const loaded = await service.loadTokens() |
| expect(loaded?.accessToken).toBe('new-fresh-token') |
| }) |
|
|
| test('returns null when refresh fails', async () => { |
| await service.saveTokens({ |
| accessToken: 'expired', |
| refreshToken: 'bad-refresh', |
| expiresAt: Date.now() + 60_000, |
| scopes: ['user:inference'], |
| subscriptionType: null, |
| }) |
| service.setRefreshFn(async () => { |
| throw new Error('401 Unauthorized') |
| }) |
|
|
| expect(await service.ensureFreshAccessToken()).toBeNull() |
| }) |
| }) |
|
|