package service import ( "net/http" "testing" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/stretchr/testify/require" ) func requireOpenAICodexProbeHeaders(t *testing.T, h http.Header) { t.Helper() require.Equal(t, codexCLIUserAgent, h.Get("User-Agent")) require.Equal(t, openai.CodexDefaultOriginator, h.Get("Originator")) require.Equal(t, codexCLIVersion, h.Get("Version")) require.Equal(t, "responses=experimental", h.Get("OpenAI-Beta")) require.NotEmpty(t, h.Get("X-Codex-Window-ID")) } // 强制统一出口:无论客户端自报什么身份,OAuth 出站的 User-Agent / originator / version // 一律是网关规范身份。上游在容量紧张时按客户端身份分优先级降载,统一出口确保没有请求 // 带着第三方或陈旧身份出站。 func TestEnsureCodexIdentityHeaders(t *testing.T) { t.Run("补齐缺失身份头", func(t *testing.T) { h := make(http.Header) ensureCodexIdentityHeaders(h) enforceCodexIdentityHeaders(h) require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator")) require.Equal(t, codexCLIUserAgent, h.Get("user-agent")) require.Equal(t, codexCLIVersion, h.Get("version")) require.Equal(t, "responses=experimental", h.Get("OpenAI-Beta")) }) t.Run("官方非 CLI 客户端身份同样被统一", func(t *testing.T) { h := make(http.Header) h.Set("user-agent", "codex_vscode/9.9.9 (Mac OS X 14.0; arm64) vscode (codex_vscode; 9.9.9)") h.Set("version", "9.9.9") h.Set("OpenAI-Beta", "assistants=v2") ensureCodexIdentityHeaders(h) enforceCodexIdentityHeaders(h) require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator")) require.Equal(t, codexCLIUserAgent, h.Get("user-agent")) require.Equal(t, codexCLIVersion, h.Get("version")) require.Equal(t, "responses=experimental", h.Get("OpenAI-Beta")) }) } func TestEnforceCodexIdentityHeaders(t *testing.T) { tests := []struct { name string originator string userAgent string version string }{ { name: "TUI 身份", originator: "codex-tui", userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)", }, { name: "错配 originator", originator: "codex_cli_rs", userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)", }, { name: "官方 vscode 身份", originator: "codex_vscode", userAgent: "codex_vscode/1.2.3 (Ubuntu 22.4.0; x86_64) vscode (codex_vscode; 1.2.3)", }, { name: "第三方客户端身份", originator: "opencode", userAgent: "luna/1.0.0", version: "2.1.0", }, { name: "浏览器型 UA(原浏览器兜底已被统一出口吸收)", originator: "codex_cli_rs", userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", }, { name: "UA 缺失", originator: "codex_vscode", }, { name: "originator override 的真实 TUI 客户端", originator: "cccc", userAgent: "cccc/0.142.0 (Ubuntu 22.4.0; x86_64) screen (codex-tui; 0.142.0)", }, { name: "陈旧客户端版本", originator: "codex_cli_rs", userAgent: "codex_cli_rs/0.125.0", version: "0.125.0", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { h := make(http.Header) h.Set("originator", tt.originator) if tt.userAgent != "" { h.Set("user-agent", tt.userAgent) } if tt.version != "" { h.Set("version", tt.version) } enforceCodexIdentityHeaders(h) require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator")) require.Equal(t, codexCLIUserAgent, h.Get("user-agent")) require.Equal(t, codexCLIVersion, h.Get("version")) }) } } // 账号级自定义 UA 是管理员的显式配置,仍然生效;但它只贡献客户端名与 OS / 架构 / 终端指纹, // originator 与版本段一律由规范身份重建,不允许出现自相矛盾或陈旧的身份。 func TestEnforceCodexIdentityHeadersWithAccountOverrideUA(t *testing.T) { t.Run("官方形态覆写 UA 保留指纹但重建版本段", func(t *testing.T) { h := make(http.Header) h.Set("originator", "codex-tui") h.Set("user-agent", "luna/1.0.0") h.Set("version", "2.1.0") enforceCodexIdentityHeadersWithUA(h, "codex_vscode/0.150.0 (Ubuntu 22.4.0; x86_64) vscode") require.Equal(t, "codex_vscode", h.Get("originator")) require.Equal(t, "codex_vscode/"+codexCLIVersion+" (Ubuntu 22.4.0; x86_64) vscode", h.Get("user-agent")) require.Equal(t, codexCLIVersion, h.Get("version")) }) t.Run("非官方形态覆写 UA 回退规范身份", func(t *testing.T) { h := make(http.Header) h.Set("originator", "codex_cli_rs") enforceCodexIdentityHeadersWithUA(h, "luna/1.0.0") require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator")) require.Equal(t, codexCLIUserAgent, h.Get("user-agent")) require.Equal(t, codexCLIVersion, h.Get("version")) }) // 回归:覆写 UA 填写于某个历史版本时,其版本段必须被重建而不是逐字沿用—— // 否则这条配置会绕过版本自动同步,把出站身份永久钉死在陈旧版本上, // 稳定落在上游优先降载的那一侧(UA 与 version 头也不再同源)。 t.Run("陈旧覆写 UA 的版本段被重建", func(t *testing.T) { h := make(http.Header) h.Set("originator", "codex_cli_rs") enforceCodexIdentityHeadersWithUA(h, "codex_cli_rs/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color") require.Equal(t, "codex_cli_rs", h.Get("originator")) require.Equal(t, "codex_cli_rs/"+codexCLIVersion+" (Ubuntu 22.4.0; x86_64) xterm-256color", h.Get("user-agent")) require.Equal(t, codexCLIVersion, h.Get("version")) require.NotContains(t, h.Get("user-agent"), "0.125.0") }) // 陈旧覆写 UA 同样跟随自动同步到的新版本,无需管理员重新编辑那条 UA。 t.Run("陈旧覆写 UA 跟随同步版本", func(t *testing.T) { SetCodexCanonicalUserAgentResolver(func() string { return "codex_cli_rs/0.200.1" + codexCLIUserAgentSuffix }) t.Cleanup(func() { SetCodexCanonicalUserAgentResolver(nil) }) h := make(http.Header) h.Set("originator", "codex_cli_rs") enforceCodexIdentityHeadersWithUA(h, "codex-tui/0.125.0 (Mac OS X 14.0; arm64) iTerm") require.Equal(t, "codex-tui", h.Get("originator")) require.Equal(t, "codex-tui/0.200.1 (Mac OS X 14.0; arm64) iTerm", h.Get("user-agent")) require.Equal(t, "0.200.1", h.Get("version")) }) } // 规范身份跟随注入的解析器(后台面板 UA / 自动同步版本号),无需重启或发版。 // // 不得给本用例加 t.Parallel():它改写进程级解析器。 func TestEnforceCodexIdentityHeadersFollowsCanonicalResolver(t *testing.T) { SetCodexCanonicalUserAgentResolver(func() string { return "codex_cli_rs/0.200.1 (Ubuntu 22.4.0; x86_64) xterm-256color" }) t.Cleanup(func() { SetCodexCanonicalUserAgentResolver(nil) }) h := make(http.Header) h.Set("originator", "codex-tui") h.Set("user-agent", "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)") enforceCodexIdentityHeaders(h) require.Equal(t, "codex_cli_rs", h.Get("originator")) require.Equal(t, "codex_cli_rs/0.200.1 (Ubuntu 22.4.0; x86_64) xterm-256color", h.Get("user-agent")) require.Equal(t, "0.200.1", h.Get("version")) } // 解析器返回非法值(配置被写坏、同步到异常内容)时必须回退到内置身份, // 绝不能把不可控内容拼进出站身份。 // // 不得给本用例加 t.Parallel():它改写进程级解析器。 func TestEnforceCodexIdentityHeadersRejectsInvalidCanonicalUA(t *testing.T) { SetCodexCanonicalUserAgentResolver(func() string { return "not-a-codex-client" }) t.Cleanup(func() { SetCodexCanonicalUserAgentResolver(nil) }) h := make(http.Header) h.Set("originator", "codex_cli_rs") enforceCodexIdentityHeaders(h) require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator")) require.Equal(t, codexCLIUserAgent, h.Get("user-agent")) require.Equal(t, codexCLIVersion, h.Get("version")) } // 开关是进程级快照,零值 Config(测试 / 工具手工构造,不经 viper)必须落在「强制统一开启」 // 一侧,否则任意一处零值构造都会静默关掉全局保护。 // // 不得给本文件的开关类用例加 t.Parallel():它们改写进程级状态。 func TestCodexIdentityEnforcementZeroValueConfigKeepsItEnabled(t *testing.T) { var cfg config.Config require.False(t, cfg.Gateway.DisableCodexIdentityEnforcement, "零值必须表示强制统一开启;若改为正向命名,零值会静默关闭保护") SetCodexIdentityEnforcementEnabled(!cfg.Gateway.DisableCodexIdentityEnforcement) t.Cleanup(func() { SetCodexIdentityEnforcementEnabled(true) }) h := make(http.Header) h.Set("originator", "codex-tui") h.Set("user-agent", "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)") enforceCodexIdentityHeaders(h) require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator")) require.Equal(t, codexCLIUserAgent, h.Get("user-agent")) } // 关闭强制统一后退回配对语义:客户端真实身份逐字保留,仅保证 originator 与 UA 首段配套 // (issue #3901),供上游策略变动时回滚。 func TestEnforceCodexIdentityHeaders_EnforcementDisabled(t *testing.T) { const tuiUA = "codex-tui/0.145.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.145.2)" SetCodexIdentityEnforcementEnabled(false) t.Cleanup(func() { SetCodexIdentityEnforcementEnabled(true) }) h := make(http.Header) h.Set("originator", "codex-tui") h.Set("user-agent", tuiUA) h.Set("version", "0.145.2") enforceCodexIdentityHeaders(h) require.Equal(t, "codex-tui", h.Get("originator")) require.Equal(t, tuiUA, h.Get("user-agent")) require.Equal(t, "0.145.2", h.Get("version")) } // 关闭强制统一后,第三方 UA 仍整体回退为规范身份并对齐 version。 func TestEnforceCodexIdentityHeaders_EnforcementDisabledThirdPartyFallback(t *testing.T) { SetCodexIdentityEnforcementEnabled(false) t.Cleanup(func() { SetCodexIdentityEnforcementEnabled(true) }) h := make(http.Header) h.Set("originator", "opencode") h.Set("user-agent", "luna/1.0.0") h.Set("version", "2.1.0") enforceCodexIdentityHeaders(h) require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator")) require.Equal(t, codexCLIUserAgent, h.Get("user-agent")) require.Equal(t, codexCLIVersion, h.Get("version")) } // 收口必须幂等:透传等路径可能先后多次经过收口。 func TestEnforceCodexIdentityHeadersIsIdempotent(t *testing.T) { h := make(http.Header) h.Set("originator", "codex-tui") h.Set("user-agent", "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)") enforceCodexIdentityHeaders(h) firstUA, firstVersion := h.Get("user-agent"), h.Get("version") enforceCodexIdentityHeaders(h) require.Equal(t, firstUA, h.Get("user-agent")) require.Equal(t, firstVersion, h.Get("version")) require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator")) } // 缺少 originator 时必须保持 no-op:compat 桥接等非 ChatGPT 内部接口路径会显式删除 // originator,不应被补回身份头。 func TestEnforceCodexIdentityHeaders_NoOriginatorIsNoop(t *testing.T) { h := make(http.Header) h.Set("user-agent", "third-party-client/1.0.0") enforceCodexIdentityHeaders(h) require.Empty(t, h.Get("originator")) require.Empty(t, h.Get("version")) require.Equal(t, "third-party-client/1.0.0", h.Get("user-agent")) } func TestNormalizeCodexClientVersion(t *testing.T) { require.Equal(t, "0.146.0", NormalizeCodexClientVersion(" 0.146.0 ")) require.Equal(t, "0.147.0-alpha.4", NormalizeCodexClientVersion("0.147.0-alpha.4")) require.Equal(t, "1.2", NormalizeCodexClientVersion("1.2")) require.Empty(t, NormalizeCodexClientVersion("")) require.Empty(t, NormalizeCodexClientVersion("v0.146.0")) require.Empty(t, NormalizeCodexClientVersion("0.146.0 (Ubuntu)")) require.Empty(t, NormalizeCodexClientVersion("0.146.0\r\nX-Injected: 1")) require.Empty(t, NormalizeCodexClientVersion("latest")) } func TestBuildCodexCLIUserAgent(t *testing.T) { require.Equal(t, openai.CodexDefaultOriginator+"/0.200.1"+codexCLIUserAgentSuffix, buildCodexCLIUserAgent("0.200.1")) // 非法版本号必须回退到内置 UA,不能拼出畸形身份。 require.Equal(t, codexCLIUserAgent, buildCodexCLIUserAgent("bogus version")) require.Equal(t, codexCLIUserAgent, buildCodexCLIUserAgent("")) } func TestCodexCanonicalUserAgentFollowsResolver(t *testing.T) { SetCodexCanonicalUserAgentResolver(func() string { return "codex_cli_rs/0.200.1" + codexCLIUserAgentSuffix }) t.Cleanup(func() { SetCodexCanonicalUserAgentResolver(nil) }) require.Equal(t, "codex_cli_rs/0.200.1"+codexCLIUserAgentSuffix, CodexCanonicalUserAgent()) require.Equal(t, "0.200.1", CodexCanonicalClientVersion()) h := make(http.Header) ApplyCodexCanonicalAuthIdentity(h) require.Equal(t, "codex_cli_rs", h.Get("originator")) require.Equal(t, "codex_cli_rs/0.200.1"+codexCLIUserAgentSuffix, h.Get("user-agent")) // 凭据面不发 version 头(真实客户端在 auth.openai.com 只带 originator + UA)。 require.Empty(t, h.Get("version")) } func TestCodexCanonicalUserAgentFallsBackWithoutResolver(t *testing.T) { SetCodexCanonicalUserAgentResolver(nil) require.Equal(t, codexCLIUserAgent, CodexCanonicalUserAgent()) require.Equal(t, codexCLIVersion, CodexCanonicalClientVersion()) }