| |
| |
| |
|
|
| 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 { createServer } from 'net' |
| import { handleHahaOpenAIOAuthApi } from '../api/haha-openai-oauth.js' |
| import { hahaOpenAIOAuthService } from '../services/hahaOpenAIOAuthService.js' |
| import { startServer } from '../index.js' |
| import { ProviderService } from '../services/providerService.js' |
|
|
| let tmpDir: string |
| let originalConfigDir: string | undefined |
|
|
| async function setup() { |
| tmpDir = await fs.mkdtemp( |
| path.join(os.tmpdir(), 'haha-openai-oauth-api-test-'), |
| ) |
| originalConfigDir = process.env.CLAUDE_CONFIG_DIR |
| process.env.CLAUDE_CONFIG_DIR = tmpDir |
| } |
|
|
| async function teardown() { |
| hahaOpenAIOAuthService.dispose() |
| hahaOpenAIOAuthService.resetCallbackPortForTests() |
| if (originalConfigDir === undefined) { |
| delete process.env.CLAUDE_CONFIG_DIR |
| } else { |
| process.env.CLAUDE_CONFIG_DIR = originalConfigDir |
| } |
| await fs.rm(tmpDir, { recursive: true, force: true }) |
| } |
|
|
| function buildReq( |
| method: string, |
| pathname: string, |
| body?: unknown, |
| ): { req: Request; url: URL; segments: string[] } { |
| const url = new URL(`http://localhost:3456${pathname}`) |
| const req = new Request(url.toString(), { |
| method, |
| headers: body ? { 'Content-Type': 'application/json' } : undefined, |
| body: body ? JSON.stringify(body) : undefined, |
| }) |
| const segments = url.pathname.split('/').filter(Boolean) |
| return { req, url, segments } |
| } |
|
|
| async function getFreePort(): Promise<number> { |
| 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)) |
| }) |
| }) |
| } |
|
|
| describe('POST /api/haha-openai-oauth/start', () => { |
| beforeEach(setup) |
| afterEach(teardown) |
|
|
| test('returns authorize URL with PKCE challenge', async () => { |
| const callbackPort = await getFreePort() |
| hahaOpenAIOAuthService.setCallbackPortForTests(callbackPort) |
|
|
| const { req, url, segments } = buildReq( |
| 'POST', |
| '/api/haha-openai-oauth/start', |
| { serverPort: 54321 }, |
| ) |
| const res = await handleHahaOpenAIOAuthApi(req, url, segments) |
| expect(res.status).toBe(200) |
| const data = (await res.json()) as { authorizeUrl: string; state: string } |
| expect(data.authorizeUrl).toContain('code_challenge_method=S256') |
| expect(data.authorizeUrl).toContain( |
| 'codex_cli_simplified_flow=true', |
| ) |
| expect(data.authorizeUrl).toContain( |
| encodeURIComponent(`http://localhost:${callbackPort}/auth/callback`), |
| ) |
| expect(data.authorizeUrl).not.toContain( |
| encodeURIComponent('http://localhost:54321/auth/callback'), |
| ) |
| expect(data.authorizeUrl).not.toContain('originator=') |
| expect(data.state).toMatch(/^[a-f0-9]{64}$/) |
| }) |
|
|
| test('400 if serverPort missing', async () => { |
| const { req, url, segments } = buildReq( |
| 'POST', |
| '/api/haha-openai-oauth/start', |
| {}, |
| ) |
| const res = await handleHahaOpenAIOAuthApi(req, url, segments) |
| expect(res.status).toBe(400) |
| const body = (await res.json()) as { error: string; message?: string } |
| expect(body.error).toBe('BAD_REQUEST') |
| }) |
| }) |
|
|
| describe('GET /api/haha-openai-oauth', () => { |
| beforeEach(setup) |
| afterEach(teardown) |
|
|
| test('returns loggedIn=false when no token file', async () => { |
| const { req, url, segments } = buildReq('GET', '/api/haha-openai-oauth') |
| const res = await handleHahaOpenAIOAuthApi(req, url, segments) |
| expect(res.status).toBe(200) |
| const data = (await res.json()) as { loggedIn: boolean } |
| expect(data.loggedIn).toBe(false) |
| }) |
|
|
| test('returns loggedIn=true + metadata when token saved', async () => { |
| await hahaOpenAIOAuthService.saveTokens({ |
| accessToken: 'openai-access-token-xxx', |
| refreshToken: 'openai-refresh-token-xxx', |
| expiresAt: Date.now() + 3600_000, |
| email: 'test@example.com', |
| accountId: 'acct_123', |
| }) |
|
|
| const { req, url, segments } = buildReq('GET', '/api/haha-openai-oauth') |
| const res = await handleHahaOpenAIOAuthApi(req, url, segments) |
| expect(res.status).toBe(200) |
| const data = (await res.json()) as { |
| loggedIn: boolean |
| expiresAt: number | null |
| email: string | null |
| accountId: string | null |
| } |
| expect(data.loggedIn).toBe(true) |
| expect(data.email).toBe('test@example.com') |
| expect(data.accountId).toBe('acct_123') |
| |
| expect(JSON.stringify(data)).not.toContain('openai-access-token') |
| expect(JSON.stringify(data)).not.toContain('openai-refresh-token') |
| }) |
|
|
| test('returns loggedIn=false when stored token is expired and refresh fails', async () => { |
| await hahaOpenAIOAuthService.saveTokens({ |
| accessToken: 'expired-token', |
| refreshToken: 'revoked-refresh-token', |
| expiresAt: Date.now() - 1_000, |
| email: 'test@example.com', |
| accountId: 'acct_123', |
| }) |
| hahaOpenAIOAuthService.setRefreshFn(async () => { |
| throw new Error('refresh revoked') |
| }) |
|
|
| const { req, url, segments } = buildReq('GET', '/api/haha-openai-oauth') |
| const res = await handleHahaOpenAIOAuthApi(req, url, segments) |
|
|
| expect(res.status).toBe(200) |
| expect(await res.json()).toEqual({ loggedIn: false }) |
| }) |
| }) |
|
|
| describe('DELETE /api/haha-openai-oauth', () => { |
| beforeEach(setup) |
| afterEach(teardown) |
|
|
| test('clears token file', async () => { |
| await hahaOpenAIOAuthService.saveTokens({ |
| accessToken: 'a', |
| refreshToken: null, |
| expiresAt: null, |
| email: null, |
| accountId: null, |
| }) |
|
|
| const { req, url, segments } = buildReq('DELETE', '/api/haha-openai-oauth') |
| const res = await handleHahaOpenAIOAuthApi(req, url, segments) |
| expect(res.status).toBe(200) |
| expect(await hahaOpenAIOAuthService.loadTokens()).toBeNull() |
| }) |
| }) |
|
|
| describe('GET /auth/callback', () => { |
| beforeEach(setup) |
| afterEach(teardown) |
|
|
| test('routes the OpenAI Codex redirect path to the desktop callback page', async () => { |
| const port = await getFreePort() |
| const originalServerPort = ProviderService.getServerPort() |
| const server = startServer(port, '127.0.0.1') |
| try { |
| const res = await fetch(`http://127.0.0.1:${port}/auth/callback`) |
| expect(res.status).toBe(200) |
| const html = await res.text() |
| expect(html).toContain('OpenAI Login Failed') |
| expect(html).toContain('Missing code or state parameter') |
| } finally { |
| server.stop(true) |
| ProviderService.setServerPort(originalServerPort) |
| } |
| }) |
| }) |
|
|