claude_code_validator_test.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600
  1. package service
  2. import (
  3. "context"
  4. "net/http"
  5. "net/http/httptest"
  6. "os"
  7. "strings"
  8. "testing"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
  10. "github.com/stretchr/testify/require"
  11. )
  12. const claudeCodeMetadataUserIDJSON = `{"device_id":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","account_uuid":"","session_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}`
  13. func TestClaudeCodeValidator_ProbeBypass(t *testing.T) {
  14. validator := NewClaudeCodeValidator()
  15. req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
  16. req.Header.Set("User-Agent", "claude-cli/1.2.3 (darwin; arm64)")
  17. req = req.WithContext(context.WithValue(req.Context(), ctxkey.IsMaxTokensOneHaikuRequest, true))
  18. ok := validator.Validate(req, map[string]any{
  19. "model": "claude-haiku-4-5",
  20. "max_tokens": 1,
  21. })
  22. require.True(t, ok)
  23. }
  24. func TestClaudeCodeValidator_ProbeBypassRequiresUA(t *testing.T) {
  25. validator := NewClaudeCodeValidator()
  26. req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
  27. req.Header.Set("User-Agent", "curl/8.0.0")
  28. req = req.WithContext(context.WithValue(req.Context(), ctxkey.IsMaxTokensOneHaikuRequest, true))
  29. ok := validator.Validate(req, map[string]any{
  30. "model": "claude-haiku-4-5",
  31. "max_tokens": 1,
  32. })
  33. require.False(t, ok)
  34. }
  35. func TestClaudeCodeValidator_MessagesWithoutProbeStillNeedStrictValidation(t *testing.T) {
  36. validator := NewClaudeCodeValidator()
  37. req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
  38. req.Header.Set("User-Agent", "claude-cli/1.2.3 (darwin; arm64)")
  39. // max_tokens=2 不是探测请求:没有 system prompt 仍走严格校验并被拒。
  40. ok := validator.Validate(req, map[string]any{
  41. "model": "claude-haiku-4-5",
  42. "max_tokens": 2,
  43. })
  44. require.False(t, ok)
  45. }
  46. func TestClaudeCodeValidator_CountTokensPathUAOnly(t *testing.T) {
  47. validator := NewClaudeCodeValidator()
  48. req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages/count_tokens", nil)
  49. req.Header.Set("User-Agent", "claude-cli/2.1.156 (Claude Code)")
  50. ok := validator.Validate(req, map[string]any{
  51. "model": "claude-opus-4-8",
  52. })
  53. require.True(t, ok)
  54. }
  55. func TestClaudeCodeValidator_CountTokensPathRequiresUA(t *testing.T) {
  56. validator := NewClaudeCodeValidator()
  57. req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages/count_tokens", nil)
  58. req.Header.Set("User-Agent", "curl/8.0.0")
  59. ok := validator.Validate(req, map[string]any{
  60. "model": "claude-opus-4-8",
  61. })
  62. require.False(t, ok)
  63. }
  64. func TestClaudeCodeValidator_MessagesPathFullValid(t *testing.T) {
  65. validator := NewClaudeCodeValidator()
  66. req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
  67. req.Header.Set("User-Agent", "claude-cli/2.1.156 (Claude Code)")
  68. req.Header.Set("X-App", "claude-code")
  69. req.Header.Set("anthropic-beta", "claude-code-20250219")
  70. req.Header.Set("anthropic-version", "2023-06-01")
  71. ok := validator.Validate(req, map[string]any{
  72. "model": "claude-opus-4-8",
  73. "stream": true,
  74. "system": []any{
  75. map[string]any{
  76. "type": "text",
  77. "text": "You are Claude Code, Anthropic's official CLI for Claude.",
  78. },
  79. },
  80. "metadata": map[string]any{
  81. "user_id": "user_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_account__session_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
  82. },
  83. })
  84. require.True(t, ok)
  85. }
  86. func TestClaudeCodeValidator_BillingBlockRecognizedWithoutIdentityPrompt(t *testing.T) {
  87. // 真实抓取的完整安全监视器 system prompt(不含身份 prose)。
  88. monitorPrompt, err := os.ReadFile("testdata/security_monitor_system_prompt.txt")
  89. require.NoError(t, err)
  90. validator := NewClaudeCodeValidator()
  91. // 前提:完整监视器正文经 Dice 相似度远低于阈值,无法被身份 prose 机制识别——
  92. // 故下面 Validate 的放行只可能来自计费归因块识别。
  93. require.Less(t, validator.bestSimilarityScore(string(monitorPrompt)), systemPromptThreshold)
  94. req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
  95. req.Header.Set("User-Agent", "claude-cli/2.1.162 (external, cli)")
  96. req.Header.Set("X-App", "cli")
  97. req.Header.Set("anthropic-beta", "claude-code-20250219")
  98. req.Header.Set("anthropic-version", "2023-06-01")
  99. // Claude Code 安全监视器子请求:不携带身份 prose,但 system 数组携带计费归因块
  100. // cc_entrypoint=cli,应据此识别为 Claude Code 客户端。
  101. ok := validator.Validate(req, map[string]any{
  102. "model": "claude-3-5-haiku-20241022",
  103. "system": []any{
  104. map[string]any{
  105. "type": "text",
  106. "text": "x-anthropic-billing-header: cc_version=2.1.162.884; cc_entrypoint=cli; cch=d8726;",
  107. },
  108. map[string]any{
  109. "type": "text",
  110. "text": string(monitorPrompt),
  111. },
  112. },
  113. "metadata": map[string]any{
  114. "user_id": claudeCodeMetadataUserIDJSON,
  115. },
  116. })
  117. require.True(t, ok)
  118. }
  119. func TestClaudeCodeValidator_SecurityMonitorWithoutBillingBlock(t *testing.T) {
  120. monitorPrompt, err := os.ReadFile("testdata/security_monitor_system_prompt.txt")
  121. require.NoError(t, err)
  122. validHeaders := map[string]string{
  123. "User-Agent": "claude-cli/2.1.220 (external, cli)",
  124. "X-App": "cli",
  125. "anthropic-beta": "claude-code-20250219",
  126. "anthropic-version": "2023-06-01",
  127. }
  128. validBody := func(prompt string) map[string]any {
  129. return map[string]any{
  130. "model": "claude-haiku-4-5-20251001",
  131. "system": []any{
  132. map[string]any{"type": "text", "text": prompt},
  133. },
  134. "metadata": map[string]any{"user_id": claudeCodeMetadataUserIDJSON},
  135. }
  136. }
  137. // 真实 CLI(2.1.220)在监视器提示词之后追加的独立会话上下文块(脱敏),
  138. // 随会话/环境变化,服务端不可控(见 issue #5152 抓包)。
  139. sessionContext := "\n\n## Session Context\n\n- **User identity**: testuser\n" +
  140. "- **Working directory**: /home/testuser/project\n- **Platform**: linux"
  141. tests := []struct {
  142. name string
  143. headers map[string]string
  144. body map[string]any
  145. wantAccept bool
  146. }{
  147. {
  148. name: "official classifier request",
  149. headers: validHeaders,
  150. body: validBody(string(monitorPrompt)),
  151. wantAccept: true,
  152. },
  153. {
  154. name: "classifier output with category element",
  155. headers: validHeaders,
  156. body: validBody(strings.Replace(
  157. string(monitorPrompt),
  158. "<block>yes</block><reason>",
  159. "<block>yes</block><category>Exact BLOCK Rule Name</category><reason>",
  160. 1,
  161. )),
  162. wantAccept: true,
  163. },
  164. {
  165. name: "non-Claude user agent",
  166. headers: map[string]string{
  167. "User-Agent": "curl/8.0.0",
  168. "X-App": "cli",
  169. "anthropic-beta": "claude-code-20250219",
  170. "anthropic-version": "2023-06-01",
  171. },
  172. body: validBody(string(monitorPrompt)),
  173. },
  174. {
  175. name: "missing X-App",
  176. headers: map[string]string{
  177. "User-Agent": validHeaders["User-Agent"],
  178. "anthropic-beta": validHeaders["anthropic-beta"],
  179. "anthropic-version": validHeaders["anthropic-version"],
  180. },
  181. body: validBody(string(monitorPrompt)),
  182. },
  183. {
  184. name: "missing anthropic-beta",
  185. headers: map[string]string{
  186. "User-Agent": validHeaders["User-Agent"],
  187. "X-App": validHeaders["X-App"],
  188. "anthropic-version": validHeaders["anthropic-version"],
  189. },
  190. body: validBody(string(monitorPrompt)),
  191. },
  192. {
  193. name: "missing anthropic-version",
  194. headers: map[string]string{
  195. "User-Agent": validHeaders["User-Agent"],
  196. "X-App": validHeaders["X-App"],
  197. "anthropic-beta": validHeaders["anthropic-beta"],
  198. },
  199. body: validBody(string(monitorPrompt)),
  200. },
  201. {
  202. name: "missing metadata",
  203. headers: validHeaders,
  204. body: map[string]any{
  205. "model": "claude-haiku-4-5-20251001",
  206. "system": []any{map[string]any{"type": "text", "text": string(monitorPrompt)}},
  207. },
  208. },
  209. {
  210. name: "invalid metadata user ID",
  211. headers: validHeaders,
  212. body: func() map[string]any {
  213. body := validBody(string(monitorPrompt))
  214. body["metadata"] = map[string]any{"user_id": "invalid"}
  215. return body
  216. }(),
  217. },
  218. {
  219. name: "unrelated prompt",
  220. headers: validHeaders,
  221. body: validBody("You are a different security classifier for coding agents."),
  222. wantAccept: false,
  223. },
  224. {
  225. name: "opening sentence alone",
  226. headers: validHeaders,
  227. body: validBody(claudeCodeSecurityMonitorPromptPrefix),
  228. wantAccept: false,
  229. },
  230. {
  231. name: "opening sentence plus arbitrary altered suffix",
  232. headers: validHeaders,
  233. body: validBody(claudeCodeSecurityMonitorPromptPrefix + "\n\n" +
  234. strings.Repeat("This is arbitrary altered classifier content. ", 300)),
  235. wantAccept: false,
  236. },
  237. {
  238. // 回归 issue #5152:真实分类器请求携带 2 个 system entry
  239. //(监视器提示词 + 追加的会话上下文块),不得因 entry 数量拒识。
  240. name: "classifier with trailing session context entry",
  241. headers: validHeaders,
  242. body: func() map[string]any {
  243. body := validBody(string(monitorPrompt))
  244. system, ok := body["system"].([]any)
  245. require.True(t, ok)
  246. body["system"] = append(system, map[string]any{
  247. "type": "text",
  248. "text": sessionContext,
  249. })
  250. return body
  251. }(),
  252. wantAccept: true,
  253. },
  254. {
  255. name: "classifier with leading session context entry",
  256. headers: validHeaders,
  257. body: func() map[string]any {
  258. body := validBody(string(monitorPrompt))
  259. system, ok := body["system"].([]any)
  260. require.True(t, ok)
  261. body["system"] = append([]any{map[string]any{
  262. "type": "text",
  263. "text": sessionContext,
  264. }}, system...)
  265. return body
  266. }(),
  267. wantAccept: true,
  268. },
  269. {
  270. name: "session context entry alone",
  271. headers: validHeaders,
  272. body: validBody(sessionContext),
  273. wantAccept: false,
  274. },
  275. {
  276. // 篡改后的长提示词(marker 缺失)即便带上会话上下文块也不得放行。
  277. name: "tampered classifier with session context entry",
  278. headers: validHeaders,
  279. body: func() map[string]any {
  280. body := validBody(strings.ReplaceAll(
  281. string(monitorPrompt), "## HARD BLOCK", "## ALTERED BLOCK"))
  282. system, ok := body["system"].([]any)
  283. require.True(t, ok)
  284. body["system"] = append(system, map[string]any{
  285. "type": "text",
  286. "text": sessionContext,
  287. })
  288. return body
  289. }(),
  290. wantAccept: false,
  291. },
  292. }
  293. validator := NewClaudeCodeValidator()
  294. for _, tt := range tests {
  295. t.Run(tt.name, func(t *testing.T) {
  296. req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
  297. for name, value := range tt.headers {
  298. req.Header.Set(name, value)
  299. }
  300. require.Equal(t, tt.wantAccept, validator.Validate(req, tt.body))
  301. })
  302. }
  303. }
  304. func TestClaudeCodeValidator_BillingBlockVSCodeEntrypointRecognized(t *testing.T) {
  305. // 回归:Claude Code 在 VSCode 扩展内运行时,计费块入口为 cc_entrypoint=claude-vscode
  306. // 而非 cli。其安全监视器子请求同样不携带身份 prose,此前写死 cc_entrypoint=cli 的
  307. // 快速通道无法识别它,导致 claude_code_only 分组误拒。入口值不应作为识别条件。
  308. monitorPrompt, err := os.ReadFile("testdata/security_monitor_system_prompt.txt")
  309. require.NoError(t, err)
  310. validator := NewClaudeCodeValidator()
  311. // 前提:完整监视器正文经 Dice 相似度远低于阈值,放行只可能来自计费归因块识别。
  312. require.Less(t, validator.bestSimilarityScore(string(monitorPrompt)), systemPromptThreshold)
  313. req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
  314. req.Header.Set("User-Agent", "claude-cli/2.1.181 (external, claude-vscode, agent-sdk/0.3.181)")
  315. req.Header.Set("X-App", "cli")
  316. req.Header.Set("anthropic-beta", "claude-code-20250219")
  317. req.Header.Set("anthropic-version", "2023-06-01")
  318. ok := validator.Validate(req, map[string]any{
  319. "model": "claude-opus-4-8",
  320. "system": []any{
  321. map[string]any{
  322. "type": "text",
  323. "text": "x-anthropic-billing-header: cc_version=2.1.181.f17; cc_entrypoint=claude-vscode;",
  324. },
  325. map[string]any{
  326. "type": "text",
  327. "text": string(monitorPrompt),
  328. },
  329. },
  330. "metadata": map[string]any{
  331. "user_id": claudeCodeMetadataUserIDJSON,
  332. },
  333. })
  334. require.True(t, ok)
  335. }
  336. func TestClaudeCodeValidator_BillingBlockWithoutEntrypointFallsThrough(t *testing.T) {
  337. validator := NewClaudeCodeValidator()
  338. req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
  339. req.Header.Set("User-Agent", "claude-cli/2.1.162 (external, cli)")
  340. req.Header.Set("X-App", "cli")
  341. req.Header.Set("anthropic-beta", "claude-code-20250219")
  342. req.Header.Set("anthropic-version", "2023-06-01")
  343. // 计费块前缀命中但完全没有 cc_entrypoint= 字段,且无身份 prose:
  344. // 不应凭前缀放行,应落回 Dice 检查并失败。验证 cc_entrypoint= 字段的存在仍是必要条件。
  345. ok := validator.Validate(req, map[string]any{
  346. "model": "claude-3-5-haiku-20241022",
  347. "system": []any{
  348. map[string]any{
  349. "type": "text",
  350. "text": "x-anthropic-billing-header: cc_version=2.1.162.884; cch=d8726;",
  351. },
  352. map[string]any{
  353. "type": "text",
  354. "text": "Some unrelated system prompt that does not resemble Claude Code.",
  355. },
  356. },
  357. "metadata": map[string]any{
  358. "user_id": claudeCodeMetadataUserIDJSON,
  359. },
  360. })
  361. require.False(t, ok)
  362. }
  363. func TestClaudeCodeValidator_BillingBlockStillRequiresClaudeCodeUA(t *testing.T) {
  364. validator := NewClaudeCodeValidator()
  365. req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
  366. req.Header.Set("User-Agent", "curl/8.0.0")
  367. req.Header.Set("X-App", "cli")
  368. req.Header.Set("anthropic-beta", "claude-code-20250219")
  369. req.Header.Set("anthropic-version", "2023-06-01")
  370. // 计费块无法绕过 UA 校验:非 claude-cli 客户端在 Step 1 即被拒。
  371. ok := validator.Validate(req, map[string]any{
  372. "model": "claude-3-5-haiku-20241022",
  373. "system": []any{
  374. map[string]any{
  375. "type": "text",
  376. "text": "x-anthropic-billing-header: cc_version=2.1.162.884; cc_entrypoint=cli; cch=d8726;",
  377. },
  378. },
  379. })
  380. require.False(t, ok)
  381. }
  382. // 新版 Claude Code CLI 已取消 cch=... 签名字段,billing block 形如
  383. // `x-anthropic-billing-header: cc_version=...; cc_entrypoint=cli;`(无 cch)。
  384. // 检测依赖前缀 + cc_entrypoint=cli,不依赖 cch,故无身份 prose 的子请求仍应被识别。
  385. // 这同时覆盖了本仓 mimicry 注入的新格式 block(见 buildBillingAttributionText)。
  386. func TestClaudeCodeValidator_BillingBlockRecognizedWithoutCCH(t *testing.T) {
  387. monitorPrompt, err := os.ReadFile("testdata/security_monitor_system_prompt.txt")
  388. require.NoError(t, err)
  389. validator := NewClaudeCodeValidator()
  390. require.Less(t, validator.bestSimilarityScore(string(monitorPrompt)), systemPromptThreshold)
  391. req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
  392. req.Header.Set("User-Agent", "claude-cli/2.1.162 (external, cli)")
  393. req.Header.Set("X-App", "cli")
  394. req.Header.Set("anthropic-beta", "claude-code-20250219")
  395. req.Header.Set("anthropic-version", "2023-06-01")
  396. ok := validator.Validate(req, map[string]any{
  397. "model": "claude-3-5-haiku-20241022",
  398. "system": []any{
  399. map[string]any{
  400. "type": "text",
  401. // 注意:无 cch 段,对齐新版 CLI 与本仓新的注入格式。
  402. "text": "x-anthropic-billing-header: cc_version=2.1.162.884; cc_entrypoint=cli;",
  403. },
  404. map[string]any{
  405. "type": "text",
  406. "text": string(monitorPrompt),
  407. },
  408. },
  409. "metadata": map[string]any{
  410. "user_id": claudeCodeMetadataUserIDJSON,
  411. },
  412. })
  413. require.True(t, ok, "无 cch 的新版 billing block 仍应被识别为 Claude Code")
  414. }
  415. // 安全回归:去掉 cch 后检测并未放松——非 claude-cli UA 即便携带无 cch 的 billing block
  416. // 仍在 Step 1 被拒,ClaudeCodeOnly group 不会因此被仿冒绕过。
  417. func TestClaudeCodeValidator_NoCCHBlockStillRequiresClaudeCodeUA(t *testing.T) {
  418. validator := NewClaudeCodeValidator()
  419. req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
  420. req.Header.Set("User-Agent", "curl/8.0.0")
  421. req.Header.Set("X-App", "cli")
  422. req.Header.Set("anthropic-beta", "claude-code-20250219")
  423. req.Header.Set("anthropic-version", "2023-06-01")
  424. ok := validator.Validate(req, map[string]any{
  425. "model": "claude-3-5-haiku-20241022",
  426. "system": []any{
  427. map[string]any{
  428. "type": "text",
  429. "text": "x-anthropic-billing-header: cc_version=2.1.162.884; cc_entrypoint=cli;",
  430. },
  431. },
  432. })
  433. require.False(t, ok)
  434. }
  435. func TestClaudeCodeValidator_MessagesPathRejectsNonClaudeCodeUA(t *testing.T) {
  436. validator := NewClaudeCodeValidator()
  437. req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
  438. req.Header.Set("User-Agent", "curl/8.0.0")
  439. req.Header.Set("X-App", "claude-code")
  440. req.Header.Set("anthropic-beta", "claude-code-20250219")
  441. req.Header.Set("anthropic-version", "2023-06-01")
  442. ok := validator.Validate(req, map[string]any{
  443. "model": "claude-opus-4-8",
  444. "stream": true,
  445. "system": []any{
  446. map[string]any{
  447. "type": "text",
  448. "text": "You are Claude Code, Anthropic's official CLI for Claude.",
  449. },
  450. },
  451. "metadata": map[string]any{
  452. "user_id": "user_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_account__session_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
  453. },
  454. })
  455. require.False(t, ok)
  456. }
  457. func TestClaudeCodeValidator_MessagesPathWithoutSystemPromptStillRejected(t *testing.T) {
  458. validator := NewClaudeCodeValidator()
  459. req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
  460. req.Header.Set("User-Agent", "claude-cli/2.1.156 (Claude Code)")
  461. req.Header.Set("X-App", "claude-code")
  462. req.Header.Set("anthropic-beta", "claude-code-20250219")
  463. req.Header.Set("anthropic-version", "2023-06-01")
  464. ok := validator.Validate(req, map[string]any{
  465. "model": "claude-opus-4-8",
  466. "stream": true,
  467. "messages": []any{
  468. map[string]any{"role": "user", "content": "hello"},
  469. },
  470. "metadata": map[string]any{
  471. "user_id": "user_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_account__session_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
  472. },
  473. })
  474. require.False(t, ok)
  475. }
  476. func TestClaudeCodeValidator_NonMessagesPathUAOnly(t *testing.T) {
  477. validator := NewClaudeCodeValidator()
  478. req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/models", nil)
  479. req.Header.Set("User-Agent", "claude-cli/1.2.3 (darwin; arm64)")
  480. ok := validator.Validate(req, nil)
  481. require.True(t, ok)
  482. }
  483. func TestExtractVersion(t *testing.T) {
  484. v := NewClaudeCodeValidator()
  485. tests := []struct {
  486. ua string
  487. want string
  488. }{
  489. {"claude-cli/2.1.22 (darwin; arm64)", "2.1.22"},
  490. {"claude-cli/1.0.0", "1.0.0"},
  491. {"Claude-CLI/3.10.5 (linux; x86_64)", "3.10.5"}, // 大小写不敏感
  492. {"curl/8.0.0", ""}, // 非 Claude CLI
  493. {"", ""}, // 空字符串
  494. {"claude-cli/", ""}, // 无版本号
  495. {"claude-cli/2.1.22-beta", "2.1.22"}, // 带后缀仍提取主版本号
  496. }
  497. for _, tt := range tests {
  498. got := v.ExtractVersion(tt.ua)
  499. require.Equal(t, tt.want, got, "ExtractVersion(%q)", tt.ua)
  500. }
  501. }
  502. func TestCompareVersions(t *testing.T) {
  503. tests := []struct {
  504. a, b string
  505. want int
  506. }{
  507. {"2.1.0", "2.1.0", 0}, // 相等
  508. {"2.1.1", "2.1.0", 1}, // patch 更大
  509. {"2.0.0", "2.1.0", -1}, // minor 更小
  510. {"3.0.0", "2.99.99", 1}, // major 更大
  511. {"1.0.0", "2.0.0", -1}, // major 更小
  512. {"0.0.1", "0.0.0", 1}, // patch 差异
  513. {"", "1.0.0", -1}, // 空字符串 vs 正常版本
  514. {"v2.1.0", "2.1.0", 0}, // v 前缀处理
  515. }
  516. for _, tt := range tests {
  517. got := CompareVersions(tt.a, tt.b)
  518. require.Equal(t, tt.want, got, "CompareVersions(%q, %q)", tt.a, tt.b)
  519. }
  520. }
  521. func TestSetGetClaudeCodeVersion(t *testing.T) {
  522. ctx := context.Background()
  523. require.Equal(t, "", GetClaudeCodeVersion(ctx), "empty context should return empty string")
  524. ctx = SetClaudeCodeVersion(ctx, "2.1.63")
  525. require.Equal(t, "2.1.63", GetClaudeCodeVersion(ctx))
  526. }
  527. func TestClaudeCodeValidator_MaxTokensOneProbeIsNotLimitedToHaiku(t *testing.T) {
  528. validator := NewClaudeCodeValidator()
  529. for _, model := range []string{"claude-sonnet-4-5", "claude-opus-4-1", "claude-haiku-4-5"} {
  530. t.Run(model, func(t *testing.T) {
  531. req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
  532. req.Header.Set("User-Agent", "claude-cli/2.1.260 (external, cli)")
  533. // No context flag, no system prompt, no extra headers: the body alone marks the probe.
  534. for _, mt := range []any{float64(1), 1} {
  535. require.True(t, validator.Validate(req, map[string]any{"model": model, "max_tokens": mt}), "max_tokens=%v (%T)", mt, mt)
  536. }
  537. })
  538. }
  539. }
  540. func TestClaudeCodeValidator_MaxTokensOneProbeStillRequiresClaudeCodeUA(t *testing.T) {
  541. validator := NewClaudeCodeValidator()
  542. req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
  543. req.Header.Set("User-Agent", "python-requests/2.32")
  544. require.False(t, validator.Validate(req, map[string]any{"model": "claude-sonnet-4-5", "max_tokens": 1}))
  545. }