cc-haha/src/services/openaiAuth/client.test.ts
程序员阿江(Relakkes) 16f4137954 fix(desktop): stabilize Electron proxy and fullscreen handling
Electron desktop runs network-sensitive OpenAI OAuth token exchange in the sidecar process, so the sidecar now receives system proxy env derived from Electron's cross-platform proxy resolver and the OAuth token client uses the existing proxy fetch options. General manual proxy settings also document and preserve authenticated proxy URLs.

The macOS fullscreen black-screen path is addressed by avoiding native fullscreen Spaces for app fullscreen toggles and by leaving fullscreen before hiding or closing the window.

Constraint: Electron packaged apps may not inherit shell HTTPS_PROXY env when launched from Finder.
Constraint: Manual authenticated proxies must remain standard HTTP(S) proxy URLs for Bun/undici compatibility.
Rejected: Store proxy username and password as separate fields | would require new secret-storage semantics and migration beyond this bugfix.
Rejected: Use native macOS fullscreen Spaces for the desktop app | reproduced black-screen behavior when hiding/closing from fullscreen.
Confidence: high
Scope-risk: moderate
Directive: Do not remove sidecar proxy env injection without retesting OpenAI OAuth from a packaged app launched outside a shell.
Tested: bun test src/services/openaiAuth/client.test.ts src/server/__tests__/haha-openai-oauth-service.test.ts
Tested: bun test src/server/__tests__/network-settings.test.ts
Tested: cd desktop && bun run test -- src/__tests__/generalSettings.test.tsx --run
Tested: cd desktop && bun run check:electron
Tested: git diff --check
Not-tested: bun run check:server blocked by expired quarantine entries server:cron-scheduler, server:providers-real, server:tasks, server:e2e:business-flow, server:e2e:full-flow
Not-tested: live OpenAI OAuth through a corporate authenticated proxy
2026-06-02 21:37:35 +08:00

213 lines
6.9 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import {
buildOpenAIAuthorizeUrl,
exchangeOpenAICodeForTokens,
generateOpenAICodeVerifier,
generateOpenAIState,
OPENAI_CODEX_CLIENT_ID,
OPENAI_CODEX_TOKEN_USER_AGENT,
refreshOpenAITokens,
} from './client.js'
describe('OpenAI Codex OAuth client', () => {
test('generates hex PKCE verifier and state like the Codex-compatible flow', () => {
const verifier = generateOpenAICodeVerifier()
const state = generateOpenAIState()
expect(verifier).toMatch(/^[a-f0-9]{128}$/)
expect(state).toMatch(/^[a-f0-9]{64}$/)
})
test('builds authorize URL without non-Codex originator parameter', () => {
const authorizeUrl = buildOpenAIAuthorizeUrl({
redirectUri: 'http://localhost:1455/auth/callback',
codeVerifier: 'a'.repeat(128),
state: 'b'.repeat(64),
})
const parsed = new URL(authorizeUrl)
const params = parsed.searchParams
expect(parsed.origin + parsed.pathname).toBe(
'https://auth.openai.com/oauth/authorize',
)
expect(params.get('client_id')).toBe(OPENAI_CODEX_CLIENT_ID)
expect(params.get('redirect_uri')).toBe(
'http://localhost:1455/auth/callback',
)
expect(params.get('scope')).toBe('openid profile email offline_access')
expect(params.get('code_challenge_method')).toBe('S256')
expect(params.get('id_token_add_organizations')).toBe('true')
expect(params.get('codex_cli_simplified_flow')).toBe('true')
expect(params.has('originator')).toBe(false)
})
test('exchanges authorization code with Codex-compatible token request headers', async () => {
const originalFetch = globalThis.fetch
let tokenRequestUrl = ''
let tokenRequestBody = ''
let tokenRequestHeaders = new Headers()
globalThis.fetch = (async (input, init) => {
tokenRequestUrl = String(input)
tokenRequestBody = String(init?.body ?? '')
tokenRequestHeaders = new Headers(init?.headers)
return new Response(
JSON.stringify({
access_token: 'access-token',
refresh_token: 'refresh-token',
expires_in: 3600,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}) as typeof fetch
try {
await exchangeOpenAICodeForTokens({
code: 'auth-code',
redirectUri: 'http://localhost:1455/auth/callback',
codeVerifier: 'verifier',
})
expect(tokenRequestUrl).toBe('https://auth.openai.com/oauth/token')
expect(tokenRequestHeaders.get('Accept')).toBe('application/json')
expect(tokenRequestHeaders.get('Content-Type')).toBe(
'application/x-www-form-urlencoded',
)
expect(tokenRequestHeaders.get('User-Agent')).toBe(
OPENAI_CODEX_TOKEN_USER_AGENT,
)
expect(tokenRequestBody).toContain('grant_type=authorization_code')
expect(tokenRequestBody).toContain('client_id=app_EMoamEEZ73f0CkXaXp7hrann')
expect(tokenRequestBody).toContain('code=auth-code')
expect(tokenRequestBody).toContain(
`redirect_uri=${encodeURIComponent('http://localhost:1455/auth/callback')}`,
)
expect(tokenRequestBody).toContain('code_verifier=verifier')
} finally {
globalThis.fetch = originalFetch
}
})
test('exchanges authorization code through configured proxy fetch options', async () => {
const originalFetch = globalThis.fetch
let tokenRequestInit: RequestInit | undefined
globalThis.fetch = (async (_input, init) => {
tokenRequestInit = init
return new Response(
JSON.stringify({
access_token: 'access-token',
refresh_token: 'refresh-token',
expires_in: 3600,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}) as typeof fetch
try {
await exchangeOpenAICodeForTokens({
code: 'auth-code',
redirectUri: 'http://localhost:1455/auth/callback',
codeVerifier: 'verifier',
proxyUrl: 'http://127.0.0.1:7890',
timeoutMs: 30_000,
})
expect((tokenRequestInit as { proxy?: string } | undefined)?.proxy).toBe(
'http://127.0.0.1:7890',
)
expect(tokenRequestInit?.signal).toBeInstanceOf(AbortSignal)
} finally {
globalThis.fetch = originalFetch
}
})
test('refreshes tokens with Codex-compatible token request headers', async () => {
const originalFetch = globalThis.fetch
let tokenRequestBody = ''
let tokenRequestHeaders = new Headers()
globalThis.fetch = (async (_input, init) => {
tokenRequestBody = String(init?.body ?? '')
tokenRequestHeaders = new Headers(init?.headers)
return new Response(
JSON.stringify({
access_token: 'access-token',
expires_in: 3600,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}) as typeof fetch
try {
await refreshOpenAITokens('refresh-token')
expect(tokenRequestHeaders.get('User-Agent')).toBe(
OPENAI_CODEX_TOKEN_USER_AGENT,
)
expect(tokenRequestBody).toContain('grant_type=refresh_token')
expect(tokenRequestBody).toContain('refresh_token=refresh-token')
expect(tokenRequestBody).toContain('scope=openid+profile+email')
} finally {
globalThis.fetch = originalFetch
}
})
test('refreshes tokens through configured proxy fetch options', async () => {
const originalFetch = globalThis.fetch
let tokenRequestInit: RequestInit | undefined
globalThis.fetch = (async (_input, init) => {
tokenRequestInit = init
return new Response(
JSON.stringify({
access_token: 'access-token',
expires_in: 3600,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}) as typeof fetch
try {
await refreshOpenAITokens('refresh-token', {
proxyUrl: 'http://127.0.0.1:7890',
timeoutMs: 30_000,
})
expect((tokenRequestInit as { proxy?: string } | undefined)?.proxy).toBe(
'http://127.0.0.1:7890',
)
expect(tokenRequestInit?.signal).toBeInstanceOf(AbortSignal)
} finally {
globalThis.fetch = originalFetch
}
})
test('includes sanitized token error response details for diagnostics', async () => {
const originalFetch = globalThis.fetch
globalThis.fetch = (async () => {
return new Response(
'{"error":"forbidden","code":"sensitive-code","refresh_token":"secret"}',
{ status: 403, headers: { 'Content-Type': 'application/json' } },
)
}) as typeof fetch
try {
await expect(
exchangeOpenAICodeForTokens({
code: 'auth-code',
redirectUri: 'http://localhost:1455/auth/callback',
codeVerifier: 'verifier',
}),
).rejects.toThrow(
'OpenAI token exchange failed: 403: {"error":"forbidden","code":"[redacted]","refresh_token":"[redacted]"}',
)
} finally {
globalThis.fetch = originalFetch
}
})
})