openai_client_restriction_detector_test.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422
  1. package service
  2. import (
  3. "net/http"
  4. "net/http/httptest"
  5. "testing"
  6. "github.com/Wei-Shaw/sub2api/internal/config"
  7. "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
  8. "github.com/gin-gonic/gin"
  9. "github.com/stretchr/testify/require"
  10. )
  11. func newCodexDetectorTestContext(ua string, originator string) *gin.Context {
  12. rec := httptest.NewRecorder()
  13. c, _ := gin.CreateTestContext(rec)
  14. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  15. if ua != "" {
  16. c.Request.Header.Set("User-Agent", ua)
  17. }
  18. if originator != "" {
  19. c.Request.Header.Set("originator", originator)
  20. }
  21. return c
  22. }
  23. func TestOpenAICodexClientRestrictionDetector_Detect(t *testing.T) {
  24. gin.SetMode(gin.TestMode)
  25. t.Run("未开启开关时绕过", func(t *testing.T) {
  26. detector := NewOpenAICodexClientRestrictionDetector(nil)
  27. account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{}}
  28. result := detector.Detect(newCodexDetectorTestContext("curl/8.0", ""), account, CodexRestrictionPolicy{}, nil)
  29. require.False(t, result.Enabled)
  30. require.False(t, result.Matched)
  31. require.Equal(t, CodexClientRestrictionReasonDisabled, result.Reason)
  32. })
  33. t.Run("开启后 codex_cli_rs 命中", func(t *testing.T) {
  34. detector := NewOpenAICodexClientRestrictionDetector(nil)
  35. account := &Account{
  36. Platform: PlatformOpenAI,
  37. Type: AccountTypeOAuth,
  38. Extra: map[string]any{"codex_cli_only": true},
  39. }
  40. result := detector.Detect(newCodexDetectorTestContext("codex_cli_rs/0.99.0", ""), account, CodexRestrictionPolicy{}, nil)
  41. require.True(t, result.Enabled)
  42. require.True(t, result.Matched)
  43. require.Equal(t, CodexClientRestrictionReasonMatchedUA, result.Reason)
  44. })
  45. t.Run("开启后 codex_vscode 命中", func(t *testing.T) {
  46. detector := NewOpenAICodexClientRestrictionDetector(nil)
  47. account := &Account{
  48. Platform: PlatformOpenAI,
  49. Type: AccountTypeOAuth,
  50. Extra: map[string]any{"codex_cli_only": true},
  51. }
  52. result := detector.Detect(newCodexDetectorTestContext("codex_vscode/1.0.0", ""), account, CodexRestrictionPolicy{}, nil)
  53. require.True(t, result.Enabled)
  54. require.True(t, result.Matched)
  55. require.Equal(t, CodexClientRestrictionReasonMatchedUA, result.Reason)
  56. })
  57. t.Run("开启后 codex_app 命中", func(t *testing.T) {
  58. detector := NewOpenAICodexClientRestrictionDetector(nil)
  59. account := &Account{
  60. Platform: PlatformOpenAI,
  61. Type: AccountTypeOAuth,
  62. Extra: map[string]any{"codex_cli_only": true},
  63. }
  64. result := detector.Detect(newCodexDetectorTestContext("codex_app/2.1.0", ""), account, CodexRestrictionPolicy{}, nil)
  65. require.True(t, result.Enabled)
  66. require.True(t, result.Matched)
  67. require.Equal(t, CodexClientRestrictionReasonMatchedUA, result.Reason)
  68. })
  69. t.Run("开启后 originator 命中", func(t *testing.T) {
  70. detector := NewOpenAICodexClientRestrictionDetector(nil)
  71. account := &Account{
  72. Platform: PlatformOpenAI,
  73. Type: AccountTypeOAuth,
  74. Extra: map[string]any{"codex_cli_only": true},
  75. }
  76. result := detector.Detect(newCodexDetectorTestContext("myterm/0.141.0", "codex_chatgpt_desktop"), account, CodexRestrictionPolicy{}, nil)
  77. require.True(t, result.Enabled)
  78. require.True(t, result.Matched)
  79. require.Equal(t, CodexClientRestrictionReasonMatchedOriginator, result.Reason)
  80. })
  81. t.Run("开启后非官方客户端拒绝", func(t *testing.T) {
  82. detector := NewOpenAICodexClientRestrictionDetector(nil)
  83. account := &Account{
  84. Platform: PlatformOpenAI,
  85. Type: AccountTypeOAuth,
  86. Extra: map[string]any{"codex_cli_only": true},
  87. }
  88. result := detector.Detect(newCodexDetectorTestContext("curl/8.0", "my_client"), account, CodexRestrictionPolicy{}, nil)
  89. require.True(t, result.Enabled)
  90. require.False(t, result.Matched)
  91. require.Equal(t, CodexClientRestrictionReasonNotMatchedUA, result.Reason)
  92. })
  93. t.Run("开启 ForceCodexCLI 时允许通过", func(t *testing.T) {
  94. detector := NewOpenAICodexClientRestrictionDetector(&config.Config{
  95. Gateway: config.GatewayConfig{ForceCodexCLI: true},
  96. })
  97. account := &Account{
  98. Platform: PlatformOpenAI,
  99. Type: AccountTypeOAuth,
  100. Extra: map[string]any{"codex_cli_only": true},
  101. }
  102. result := detector.Detect(newCodexDetectorTestContext("curl/8.0", "my_client"), account, CodexRestrictionPolicy{}, nil)
  103. require.True(t, result.Enabled)
  104. require.True(t, result.Matched)
  105. require.Equal(t, CodexClientRestrictionReasonForceCodexCLI, result.Reason)
  106. })
  107. }
  108. func TestOpenAICodexClientRestrictionDetector_Detect_AllowedClients(t *testing.T) {
  109. gin.SetMode(gin.TestMode)
  110. const (
  111. claudeCodeUA = "Claude Code/0.5.0 (Macos 15.5; arm64) iTerm2.app (Claude Code; 1.0.4)"
  112. claudeCodeOriginator = "Claude Code"
  113. )
  114. t.Run("未配置白名单时 Claude Code 签名仍拒绝", func(t *testing.T) {
  115. detector := NewOpenAICodexClientRestrictionDetector(nil)
  116. account := &Account{
  117. Platform: PlatformOpenAI,
  118. Type: AccountTypeOAuth,
  119. Extra: map[string]any{"codex_cli_only": true},
  120. }
  121. result := detector.Detect(newCodexDetectorTestContext(claudeCodeUA, claudeCodeOriginator), account, CodexRestrictionPolicy{}, nil)
  122. require.True(t, result.Enabled)
  123. require.False(t, result.Matched)
  124. require.Equal(t, CodexClientRestrictionReasonNotMatchedUA, result.Reason)
  125. })
  126. t.Run("未开启 codex_cli_only 时直接绕过", func(t *testing.T) {
  127. detector := NewOpenAICodexClientRestrictionDetector(nil)
  128. account := &Account{
  129. Platform: PlatformOpenAI,
  130. Type: AccountTypeOAuth,
  131. Extra: map[string]any{},
  132. }
  133. result := detector.Detect(newCodexDetectorTestContext(claudeCodeUA, claudeCodeOriginator), account, CodexRestrictionPolicy{}, nil)
  134. require.False(t, result.Enabled)
  135. require.False(t, result.Matched)
  136. require.Equal(t, CodexClientRestrictionReasonDisabled, result.Reason)
  137. })
  138. t.Run("全局白名单含 Claude Code 签名 → 放行(whitelist)", func(t *testing.T) {
  139. detector := NewOpenAICodexClientRestrictionDetector(nil)
  140. account := &Account{
  141. Platform: PlatformOpenAI,
  142. Type: AccountTypeOAuth,
  143. Extra: map[string]any{"codex_cli_only": true},
  144. }
  145. result := detector.Detect(
  146. newCodexDetectorTestContext("Claude Code/0.5.0 (Macos 15.5; arm64) iTerm2.app (Claude Code; 1.0.4)", "Claude Code"),
  147. account,
  148. CodexRestrictionPolicy{Whitelist: []openai.AllowedClientEntry{{Originator: "Claude Code", UAContains: []string{"Claude Code/"}}}},
  149. nil,
  150. )
  151. require.True(t, result.Enabled)
  152. require.True(t, result.Matched)
  153. require.Equal(t, CodexClientRestrictionReasonMatchedWhitelistClient, result.Reason)
  154. })
  155. t.Run("全局白名单含 Claude Code + 非签名 → 403", func(t *testing.T) {
  156. detector := NewOpenAICodexClientRestrictionDetector(nil)
  157. account := &Account{
  158. Platform: PlatformOpenAI,
  159. Type: AccountTypeOAuth,
  160. Extra: map[string]any{"codex_cli_only": true},
  161. }
  162. result := detector.Detect(
  163. newCodexDetectorTestContext("curl/8.0", "my_client"),
  164. account,
  165. CodexRestrictionPolicy{Whitelist: []openai.AllowedClientEntry{{Originator: "Claude Code", UAContains: []string{"Claude Code/"}}}},
  166. nil,
  167. )
  168. require.True(t, result.Enabled)
  169. require.False(t, result.Matched)
  170. require.Equal(t, CodexClientRestrictionReasonNotMatchedUA, result.Reason)
  171. })
  172. t.Run("全局列表为空 + 账号未配 → 403", func(t *testing.T) {
  173. detector := NewOpenAICodexClientRestrictionDetector(nil)
  174. account := &Account{
  175. Platform: PlatformOpenAI,
  176. Type: AccountTypeOAuth,
  177. Extra: map[string]any{"codex_cli_only": true},
  178. }
  179. result := detector.Detect(
  180. newCodexDetectorTestContext("Claude Code/0.5.0 (Macos) (Claude Code; 1.0.4)", "Claude Code"),
  181. account,
  182. CodexRestrictionPolicy{},
  183. nil,
  184. )
  185. require.True(t, result.Enabled)
  186. require.False(t, result.Matched)
  187. require.Equal(t, CodexClientRestrictionReasonNotMatchedUA, result.Reason)
  188. })
  189. }
  190. func TestDetect_V3_AppServerAndSkipAndVersionScope(t *testing.T) {
  191. gin.SetMode(gin.TestMode)
  192. acc := func() *Account {
  193. return &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{"codex_cli_only": true}}
  194. }
  195. t.Run("AppServer OFF:未列名客户端拒", func(t *testing.T) {
  196. d := NewOpenAICodexClientRestrictionDetector(nil)
  197. r := d.Detect(newCodexDetectorTestContext("opencode/1.0", "opencode"), acc(), CodexRestrictionPolicy{}, nil)
  198. require.False(t, r.Matched)
  199. require.Equal(t, CodexClientRestrictionReasonNotMatchedUA, r.Reason)
  200. })
  201. t.Run("AppServer ON + 引擎头 → 放行(app_server)", func(t *testing.T) {
  202. d := NewOpenAICodexClientRestrictionDetector(nil)
  203. c := newCodexDetectorTestContext("opencode/1.0", "opencode")
  204. c.Request.Header.Set("x-codex-window-id", "1")
  205. r := d.Detect(c, acc(), CodexRestrictionPolicy{AllowAppServerClients: true, EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals}, nil)
  206. require.True(t, r.Matched)
  207. require.Equal(t, CodexClientRestrictionReasonMatchedAppServerClient, r.Reason)
  208. })
  209. t.Run("AppServer ON + 无引擎头 + strict → 拒", func(t *testing.T) {
  210. d := NewOpenAICodexClientRestrictionDetector(nil)
  211. r := d.Detect(newCodexDetectorTestContext("opencode/1.0", "opencode"), acc(), CodexRestrictionPolicy{AllowAppServerClients: true, EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals}, nil)
  212. require.False(t, r.Matched)
  213. require.Equal(t, CodexClientRestrictionReasonMissingEngineFingerprint, r.Reason)
  214. })
  215. t.Run("白名单 skip=true + 无引擎头 + strict → 放行", func(t *testing.T) {
  216. d := NewOpenAICodexClientRestrictionDetector(nil)
  217. pol := CodexRestrictionPolicy{
  218. Whitelist: []openai.AllowedClientEntry{{Originator: "opencode", UAContains: []string{"opencode/"}, SkipEngineFingerprint: true}},
  219. }
  220. r := d.Detect(newCodexDetectorTestContext("opencode/1.0", "opencode"), acc(), pol, nil)
  221. require.True(t, r.Matched)
  222. require.Equal(t, CodexClientRestrictionReasonMatchedWhitelistClient, r.Reason)
  223. })
  224. t.Run("白名单 skip=false + 无引擎头 + strict → 拒", func(t *testing.T) {
  225. d := NewOpenAICodexClientRestrictionDetector(nil)
  226. pol := CodexRestrictionPolicy{
  227. EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals,
  228. Whitelist: []openai.AllowedClientEntry{{Originator: "opencode", UAContains: []string{"opencode/"}}},
  229. }
  230. r := d.Detect(newCodexDetectorTestContext("opencode/1.0", "opencode"), acc(), pol, nil)
  231. require.False(t, r.Matched)
  232. require.Equal(t, CodexClientRestrictionReasonMissingEngineFingerprint, r.Reason)
  233. })
  234. t.Run("版本门仅官方:白名单无版本不拒", func(t *testing.T) {
  235. d := NewOpenAICodexClientRestrictionDetector(nil)
  236. pol := CodexRestrictionPolicy{
  237. Whitelist: []openai.AllowedClientEntry{{Originator: "opencode", UAContains: []string{"opencode"}}},
  238. }
  239. r := d.Detect(newCodexDetectorTestContext("opencode", "opencode"), acc(), pol, nil)
  240. require.True(t, r.Matched)
  241. require.Equal(t, CodexClientRestrictionReasonMatchedWhitelistClient, r.Reason)
  242. })
  243. t.Run("版本门仍卡官方:官方 originator 无版本 → VersionUndetectable", func(t *testing.T) {
  244. d := NewOpenAICodexClientRestrictionDetector(nil)
  245. r := d.Detect(newCodexDetectorTestContext("noversion", "codex_cli_rs"), acc(), CodexRestrictionPolicy{}, nil)
  246. require.False(t, r.Matched)
  247. require.Equal(t, CodexClientRestrictionReasonVersionUndetectable, r.Reason)
  248. })
  249. }
  250. func TestDetect_VersionGateCarriesVersionFields(t *testing.T) {
  251. gin.SetMode(gin.TestMode)
  252. d := NewOpenAICodexClientRestrictionDetector(nil)
  253. acc := func() *Account {
  254. return &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{"codex_cli_only": true}}
  255. }
  256. t.Run("版本太低:携带 DetectedVersion + MinCodexVersion", func(t *testing.T) {
  257. c := newCodexDetectorTestContext("codex_cli_rs/0.39.0 (x)", "")
  258. r := d.Detect(c, acc(), CodexRestrictionPolicy{MinCodexVersion: "0.42.0"}, nil)
  259. require.False(t, r.Matched)
  260. require.Equal(t, CodexClientRestrictionReasonVersionTooLow, r.Reason)
  261. require.Equal(t, "0.39.0", r.DetectedVersion)
  262. require.Equal(t, "0.42.0", r.MinCodexVersion)
  263. })
  264. t.Run("版本太高:携带 DetectedVersion + MaxCodexVersion", func(t *testing.T) {
  265. c := newCodexDetectorTestContext("codex_cli_rs/0.45.0 (x)", "")
  266. r := d.Detect(c, acc(), CodexRestrictionPolicy{MaxCodexVersion: "0.42.0"}, nil)
  267. require.False(t, r.Matched)
  268. require.Equal(t, CodexClientRestrictionReasonVersionTooHigh, r.Reason)
  269. require.Equal(t, "0.45.0", r.DetectedVersion)
  270. require.Equal(t, "0.42.0", r.MaxCodexVersion)
  271. })
  272. }
  273. func TestCodexClientRestrictionMessage(t *testing.T) {
  274. t.Run("版本太低:带实际版本与最低要求", func(t *testing.T) {
  275. msg := CodexClientRestrictionMessage(CodexClientRestrictionDetectionResult{
  276. Reason: CodexClientRestrictionReasonVersionTooLow,
  277. DetectedVersion: "0.39.0",
  278. MinCodexVersion: "0.42.0",
  279. })
  280. require.Equal(t, "Your Codex version (0.39.0) is below the minimum required version (0.42.0). Please update Codex.", msg)
  281. })
  282. t.Run("版本太高:带实际版本与最高允许", func(t *testing.T) {
  283. msg := CodexClientRestrictionMessage(CodexClientRestrictionDetectionResult{
  284. Reason: CodexClientRestrictionReasonVersionTooHigh,
  285. DetectedVersion: "0.45.0",
  286. MaxCodexVersion: "0.42.0",
  287. })
  288. require.Equal(t, "Your Codex version (0.45.0) exceeds the maximum allowed version (0.42.0). Please downgrade Codex to 0.42.0 or lower.", msg)
  289. })
  290. t.Run("无法识别版本:保持原通用句", func(t *testing.T) {
  291. msg := CodexClientRestrictionMessage(CodexClientRestrictionDetectionResult{
  292. Reason: CodexClientRestrictionReasonVersionUndetectable,
  293. })
  294. require.Equal(t, "This account only allows Codex official clients", msg)
  295. })
  296. t.Run("未命中官方:保持原通用句", func(t *testing.T) {
  297. msg := CodexClientRestrictionMessage(CodexClientRestrictionDetectionResult{
  298. Reason: CodexClientRestrictionReasonNotMatchedUA,
  299. })
  300. require.Equal(t, "This account only allows Codex official clients", msg)
  301. })
  302. }
  303. func TestDetect_EngineFingerprintSignals(t *testing.T) {
  304. gin.SetMode(gin.TestMode)
  305. det := NewOpenAICodexClientRestrictionDetector(&config.Config{})
  306. acct := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{"codex_cli_only": true}}
  307. officialUA := "codex_cli_rs/0.141.0 (x) (codex_cli_rs; 0.141.0)"
  308. policy := CodexRestrictionPolicy{
  309. EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals, // 只勾 x-codex-
  310. }
  311. t.Run("官方UA+带x-codex-头 → 放行", func(t *testing.T) {
  312. c := newCodexDetectorTestContext(officialUA, "")
  313. c.Request.Header.Set("x-codex-window-id", "a1")
  314. got := det.Detect(c, acct, policy, nil)
  315. require.True(t, got.Matched)
  316. })
  317. t.Run("官方UA+无x-codex-头 → 拒(缺指纹)", func(t *testing.T) {
  318. c := newCodexDetectorTestContext(officialUA, "")
  319. c.Request.Header.Set("session-id", "u1") // 默认 session 未勾,不满足必须项
  320. got := det.Detect(c, acct, policy, nil)
  321. require.False(t, got.Matched)
  322. require.Equal(t, CodexClientRestrictionReasonMissingEngineFingerprint, got.Reason)
  323. })
  324. t.Run("body通道: 勾body_path后 仅body命中 → 放行", func(t *testing.T) {
  325. bodyPolicy := CodexRestrictionPolicy{
  326. EngineFingerprintSignals: []openai.EngineFingerprintSignal{
  327. {Type: openai.FingerprintSignalBodyPath, Match: []string{"client_metadata.x-codex-window-id"}, Required: true},
  328. },
  329. }
  330. c := newCodexDetectorTestContext(officialUA, "")
  331. got := det.Detect(c, acct, bodyPolicy, []byte(`{"client_metadata":{"x-codex-window-id":"c3"}}`))
  332. require.True(t, got.Matched)
  333. })
  334. }
  335. func TestDetect_AccountAppServerToggle(t *testing.T) {
  336. gin.SetMode(gin.TestMode)
  337. d := NewOpenAICodexClientRestrictionDetector(nil)
  338. acctOn := func() *Account {
  339. return &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{"codex_cli_only": true, "codex_cli_only_allow_app_server": true}}
  340. }
  341. acctOff := func() *Account {
  342. return &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{"codex_cli_only": true}}
  343. }
  344. withFP := func(ua, originator string) *gin.Context {
  345. c := newCodexDetectorTestContext(ua, originator)
  346. c.Request.Header.Set("x-codex-window-id", "1")
  347. return c
  348. }
  349. defaultSignals := CodexRestrictionPolicy{EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals}
  350. t.Run("账号 app-server ON + 引擎头 → 放行(全局 OFF 也放行)", func(t *testing.T) {
  351. r := d.Detect(withFP("opencode/1.0", "opencode"), acctOn(), defaultSignals, nil)
  352. require.True(t, r.Matched)
  353. require.Equal(t, CodexClientRestrictionReasonMatchedAppServerClient, r.Reason)
  354. })
  355. t.Run("账号 app-server ON + 无引擎头 → 拒(缺指纹)", func(t *testing.T) {
  356. r := d.Detect(newCodexDetectorTestContext("opencode/1.0", "opencode"), acctOn(), defaultSignals, nil)
  357. require.False(t, r.Matched)
  358. require.Equal(t, CodexClientRestrictionReasonMissingEngineFingerprint, r.Reason)
  359. })
  360. t.Run("账号 app-server OFF + 全局 OFF → 拒(未命中)", func(t *testing.T) {
  361. r := d.Detect(withFP("opencode/1.0", "opencode"), acctOff(), defaultSignals, nil)
  362. require.False(t, r.Matched)
  363. require.Equal(t, CodexClientRestrictionReasonNotMatchedUA, r.Reason)
  364. })
  365. t.Run("账号 app-server OFF + 全局 ON → 放行(OR)", func(t *testing.T) {
  366. pol := CodexRestrictionPolicy{AllowAppServerClients: true, EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals}
  367. r := d.Detect(withFP("opencode/1.0", "opencode"), acctOff(), pol, nil)
  368. require.True(t, r.Matched)
  369. require.Equal(t, CodexClientRestrictionReasonMatchedAppServerClient, r.Reason)
  370. })
  371. }