/** * Unit tests for HahaOpenAIOAuthService — haha 自管 OpenAI OAuth 的核心 service 层。 */ import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test' import * as fs from 'fs/promises' import * as path from 'path' import * as os from 'os' import { createConnection, createServer } from 'net' import { HahaOpenAIOAuthService, getHahaOpenAIOAuthFilePath, type StoredOpenAIOAuthTokens, } from '../services/hahaOpenAIOAuthService.js' let tmpDir: string let originalConfigDir: string | undefined let service: HahaOpenAIOAuthService let callbackPort: number async function getFreePort(): Promise { return await new Promise((resolve, reject) => { const server = createServer() server.on('error', reject) server.listen(0, '127.0.0.1', () => { const address = server.address() if (!address || typeof address === 'string') { server.close(() => reject(new Error('Failed to allocate test port'))) return } const port = address.port server.close(() => resolve(port)) }) }) } async function getLocalCallback( callbackPath: string, ): Promise<{ status: number; body: string }> { return await new Promise((resolve, reject) => { const socket = createConnection( { host: 'localhost', port: callbackPort }, () => { socket.write( `GET ${callbackPath} HTTP/1.1\r\nHost: localhost:${callbackPort}\r\nConnection: close\r\n\r\n`, ) }, ) let raw = '' socket.setEncoding('utf8') socket.on('data', (chunk) => { raw += chunk }) socket.on('end', () => { const status = Number.parseInt( raw.match(/^HTTP\/1\.[01] (\d{3})/)?.[1] ?? '0', 10, ) const body = raw.split('\r\n\r\n').slice(1).join('\r\n\r\n') resolve({ status, body }) }) socket.on('error', reject) }) } function mockJwt(payload: Record): string { const encode = (value: Record) => Buffer.from(JSON.stringify(value)).toString('base64url') return `${encode({ alg: 'none' })}.${encode(payload)}.signature` } async function setup() { tmpDir = await fs.mkdtemp( path.join(os.tmpdir(), 'haha-openai-oauth-test-'), ) originalConfigDir = process.env.CLAUDE_CONFIG_DIR process.env.CLAUDE_CONFIG_DIR = tmpDir callbackPort = await getFreePort() service = new HahaOpenAIOAuthService({ callbackPort }) } async function teardown() { service.dispose() 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('HahaOpenAIOAuthService — 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: StoredOpenAIOAuthTokens = { accessToken: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.mock-access', refreshToken: 'eyJhbGciOiJSUzI1NiJ9.mock-refresh', expiresAt: Date.now() + 3600_000, idToken: 'mock-id-token', email: 'test@example.com', accountId: 'acct_123', clientId: 'app_EMoamEEZ73f0CkXaXp7hrann', } await service.saveTokens(tokens) const oauthPath = getHahaOpenAIOAuthFilePath() 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, email: null, accountId: null, }) await service.deleteTokens() expect(await service.loadTokens()).toBeNull() }) test('saveTokens cleans up tmp file when rename fails', async () => { const renameSpy = spyOn(fs, 'rename').mockImplementation(async () => { const error = new Error('rename failed') as NodeJS.ErrnoException error.code = 'EXDEV' throw error }) try { await expect( service.saveTokens({ accessToken: 'sensitive-access', refreshToken: 'sensitive-refresh', expiresAt: Date.now() + 3600_000, idToken: 'sensitive-id-token', email: 'test@example.com', accountId: 'acct_123', }), ).rejects.toThrow('rename failed') } finally { renameSpy.mockRestore() } const oauthPath = getHahaOpenAIOAuthFilePath() const files = await fs.readdir(path.dirname(oauthPath)) expect( files.filter((name) => name.startsWith('openai-oauth.json.tmp.')), ).toEqual([]) expect(await service.loadTokens()).toBeNull() }) }) describe('HahaOpenAIOAuthService — session management', () => { beforeEach(setup) afterEach(teardown) test('startSession creates session with PKCE + fixed Codex callback port', async () => { const session = await service.startSession({ serverPort: 54321 }) expect(session.state).toMatch(/^[a-f0-9]{64}$/) expect(session.codeVerifier).toMatch(/^[a-f0-9]{128}$/) expect(session.authorizeUrl).toContain('code_challenge_method=S256') expect(session.authorizeUrl).toContain( `state=${encodeURIComponent(session.state)}`, ) expect(session.authorizeUrl).toContain( 'codex_cli_simplified_flow=true', ) expect(session.authorizeUrl).toContain( encodeURIComponent(`http://localhost:${callbackPort}/auth/callback`), ) expect(session.authorizeUrl).not.toContain( encodeURIComponent('http://localhost:54321/auth/callback'), ) expect(session.authorizeUrl).not.toContain('originator=') }) test('getSession returns stored session by state', async () => { const session = await 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', async () => { const session = await service.startSession({ serverPort: 54321 }) expect(service.consumeSession(session.state)).not.toBeNull() expect(service.getSession(session.state)).toBeNull() }) test('callback listener exchanges the authorization code and saves tokens', async () => { const originalFetch = globalThis.fetch const session = await service.startSession({ serverPort: 54321 }) let tokenRequestBody = '' globalThis.fetch = (async (_url, init) => { tokenRequestBody = String(init?.body ?? '') return new Response( JSON.stringify({ access_token: 'openai-access-token', refresh_token: 'openai-refresh-token', expires_in: 3600, id_token: mockJwt({ email: 'test@example.com', 'https://api.openai.com/auth': { chatgpt_account_id: 'acct_123', }, }), }), { status: 200, headers: { 'Content-Type': 'application/json' } }, ) }) as typeof fetch try { const res = await getLocalCallback( `/auth/callback?code=auth-code&state=${session.state}`, ) expect(res.status).toBe(200) expect(res.body).toContain('OpenAI Login Successful') expect(tokenRequestBody).toContain('code=auth-code') expect(tokenRequestBody).toContain( `redirect_uri=${encodeURIComponent(`http://localhost:${callbackPort}/auth/callback`)}`, ) expect(tokenRequestBody).toContain( `code_verifier=${session.codeVerifier}`, ) const tokens = await service.loadTokens() expect(tokens?.accessToken).toBe('openai-access-token') expect(tokens?.refreshToken).toBe('openai-refresh-token') expect(tokens?.email).toBe('test@example.com') expect(tokens?.accountId).toBe('acct_123') } finally { globalThis.fetch = originalFetch } }) test('callback listener renders an error page when token exchange fails', async () => { const originalFetch = globalThis.fetch const session = await service.startSession({ serverPort: 54321 }) globalThis.fetch = (async () => { return new Response('bad request', { status: 400 }) }) as typeof fetch try { const res = await getLocalCallback( `/auth/callback?code=bad-code&state=${session.state}`, ) expect(res.status).toBe(200) expect(res.body).toContain('OpenAI Login Failed') expect(res.body).toContain('OpenAI token exchange failed: 400') expect(await service.loadTokens()).toBeNull() } finally { globalThis.fetch = originalFetch } }) test('callback listener clears the session after an invalid callback', async () => { const consoleSpy = spyOn(console, 'error').mockImplementation(() => {}) const session = await service.startSession({ serverPort: 54321 }) try { const res = await getLocalCallback(`/auth/callback?state=${session.state}`) expect(res.status).toBe(400) expect(res.body).toContain('Authorization code not found') await new Promise((resolve) => setTimeout(resolve, 0)) expect(service.getSession(session.state)).toBeNull() } finally { consoleSpy.mockRestore() } }) }) describe('HahaOpenAIOAuthService — 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: StoredOpenAIOAuthTokens = { accessToken: 'still-valid', refreshToken: 'refresh-xxx', expiresAt: Date.now() + 30 * 60_000, email: 'test@example.com', accountId: 'acct_123', } await service.saveTokens(tokens) expect(await service.ensureFreshAccessToken()).toBe('still-valid') }) test('returns null when tokens expired and no refresh token', async () => { await service.saveTokens({ accessToken: 'expired', refreshToken: null, expiresAt: Date.now() - 1_000, email: null, accountId: null, }) expect(await service.ensureFreshAccessToken()).toBeNull() }) test('refreshes token when expired (within 5-min buffer)', async () => { await service.saveTokens({ accessToken: 'expired', refreshToken: 'refresh-xxx', expiresAt: Date.now() + 60_000, email: 'test@example.com', accountId: 'acct_123', }) service.setRefreshFn(async () => ({ access_token: 'new-fresh-token', refresh_token: 'new-refresh-xxx', expires_in: 3600, id_token: 'mock-id-token', })) const fresh = await service.ensureFreshAccessToken() expect(fresh).toBe('new-fresh-token') const loaded = await service.loadTokens() expect(loaded?.accessToken).toBe('new-fresh-token') }) test('preserves existing refresh token and id token when refresh omits them', async () => { await service.saveTokens({ accessToken: 'expired', refreshToken: 'refresh-to-preserve', expiresAt: Date.now() + 60_000, idToken: 'id-token-to-preserve', email: 'test@example.com', accountId: 'acct_123', clientId: 'app_EMoamEEZ73f0CkXaXp7hrann', }) service.setRefreshFn(async () => ({ access_token: 'new-access-token', expires_in: 3600, })) const fresh = await service.ensureFreshAccessToken() expect(fresh).toBe('new-access-token') const loaded = await service.loadTokens() expect(loaded?.refreshToken).toBe('refresh-to-preserve') expect(loaded?.idToken).toBe('id-token-to-preserve') expect(loaded?.clientId).toBe('app_EMoamEEZ73f0CkXaXp7hrann') }) test('returns null when refresh fails', async () => { await service.saveTokens({ accessToken: 'expired', refreshToken: 'bad-refresh', expiresAt: Date.now() + 60_000, email: null, accountId: null, }) service.setRefreshFn(async () => { throw new Error('401 Unauthorized') }) expect(await service.ensureFreshAccessToken()).toBeNull() }) })