claude_code_detection_test.go 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "net/http/httptest"
  6. "testing"
  7. "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
  8. "github.com/stretchr/testify/require"
  9. )
  10. func newTestValidator() *ClaudeCodeValidator {
  11. return NewClaudeCodeValidator()
  12. }
  13. // validClaudeCodeBody 构造一个完整有效的 Claude Code 请求体
  14. func validClaudeCodeBody() map[string]any {
  15. return map[string]any{
  16. "model": "claude-sonnet-4-20250514",
  17. "system": []any{
  18. map[string]any{
  19. "type": "text",
  20. "text": "You are Claude Code, Anthropic's official CLI for Claude.",
  21. },
  22. },
  23. "metadata": map[string]any{
  24. "user_id": "user_" + "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + "_account__session_" + "12345678-1234-1234-1234-123456789abc",
  25. },
  26. }
  27. }
  28. func TestValidate_ClaudeCLIUserAgent(t *testing.T) {
  29. v := newTestValidator()
  30. tests := []struct {
  31. name string
  32. ua string
  33. want bool
  34. }{
  35. {"标准版本号", "claude-cli/1.0.0", true},
  36. {"多位版本号", "claude-cli/12.34.56", true},
  37. {"大写开头", "Claude-CLI/1.0.0", true},
  38. {"非 claude-cli", "curl/7.64.1", false},
  39. {"空 User-Agent", "", false},
  40. {"部分匹配", "not-claude-cli/1.0.0", false},
  41. {"缺少版本号", "claude-cli/", false},
  42. {"版本格式不对", "claude-cli/1.0", false},
  43. }
  44. for _, tt := range tests {
  45. t.Run(tt.name, func(t *testing.T) {
  46. require.Equal(t, tt.want, v.ValidateUserAgent(tt.ua), "UA: %q", tt.ua)
  47. })
  48. }
  49. }
  50. func TestValidate_NonMessagesPath_UAOnly(t *testing.T) {
  51. v := newTestValidator()
  52. // 非 messages 路径只检查 UA
  53. req := httptest.NewRequest("GET", "/v1/models", nil)
  54. req.Header.Set("User-Agent", "claude-cli/1.0.0")
  55. result := v.Validate(req, nil)
  56. require.True(t, result, "非 messages 路径只需 UA 匹配")
  57. }
  58. func TestValidate_NonMessagesPath_InvalidUA(t *testing.T) {
  59. v := newTestValidator()
  60. req := httptest.NewRequest("GET", "/v1/models", nil)
  61. req.Header.Set("User-Agent", "curl/7.64.1")
  62. result := v.Validate(req, nil)
  63. require.False(t, result, "UA 不匹配时应返回 false")
  64. }
  65. func TestValidate_MessagesPath_FullValid(t *testing.T) {
  66. v := newTestValidator()
  67. req := httptest.NewRequest("POST", "/v1/messages", nil)
  68. req.Header.Set("User-Agent", "claude-cli/1.0.0")
  69. req.Header.Set("X-App", "claude-code")
  70. req.Header.Set("anthropic-beta", "max-tokens-3-5-sonnet-2024-07-15")
  71. req.Header.Set("anthropic-version", "2023-06-01")
  72. result := v.Validate(req, validClaudeCodeBody())
  73. require.True(t, result, "完整有效请求应通过")
  74. }
  75. func TestValidate_MessagesPath_MissingHeaders(t *testing.T) {
  76. v := newTestValidator()
  77. body := validClaudeCodeBody()
  78. tests := []struct {
  79. name string
  80. missingHeader string
  81. }{
  82. {"缺少 X-App", "X-App"},
  83. {"缺少 anthropic-beta", "anthropic-beta"},
  84. {"缺少 anthropic-version", "anthropic-version"},
  85. }
  86. for _, tt := range tests {
  87. t.Run(tt.name, func(t *testing.T) {
  88. req := httptest.NewRequest("POST", "/v1/messages", nil)
  89. req.Header.Set("User-Agent", "claude-cli/1.0.0")
  90. req.Header.Set("X-App", "claude-code")
  91. req.Header.Set("anthropic-beta", "beta")
  92. req.Header.Set("anthropic-version", "2023-06-01")
  93. req.Header.Del(tt.missingHeader)
  94. result := v.Validate(req, body)
  95. require.False(t, result, "缺少 %s 应返回 false", tt.missingHeader)
  96. })
  97. }
  98. }
  99. func TestValidate_MessagesPath_InvalidMetadataUserID(t *testing.T) {
  100. v := newTestValidator()
  101. tests := []struct {
  102. name string
  103. metadata map[string]any
  104. }{
  105. {"缺少 metadata", nil},
  106. {"缺少 user_id", map[string]any{"other": "value"}},
  107. {"空 user_id", map[string]any{"user_id": ""}},
  108. {"格式错误", map[string]any{"user_id": "invalid-format"}},
  109. {"hex 长度不足", map[string]any{"user_id": "user_abc_account__session_uuid"}},
  110. }
  111. for _, tt := range tests {
  112. t.Run(tt.name, func(t *testing.T) {
  113. req := httptest.NewRequest("POST", "/v1/messages", nil)
  114. req.Header.Set("User-Agent", "claude-cli/1.0.0")
  115. req.Header.Set("X-App", "claude-code")
  116. req.Header.Set("anthropic-beta", "beta")
  117. req.Header.Set("anthropic-version", "2023-06-01")
  118. body := map[string]any{
  119. "model": "claude-sonnet-4",
  120. "system": []any{
  121. map[string]any{
  122. "type": "text",
  123. "text": "You are Claude Code, Anthropic's official CLI for Claude.",
  124. },
  125. },
  126. }
  127. if tt.metadata != nil {
  128. body["metadata"] = tt.metadata
  129. }
  130. result := v.Validate(req, body)
  131. require.False(t, result, "metadata.user_id: %v", tt.metadata)
  132. })
  133. }
  134. }
  135. func TestValidate_MessagesPath_InvalidSystemPrompt(t *testing.T) {
  136. v := newTestValidator()
  137. req := httptest.NewRequest("POST", "/v1/messages", nil)
  138. req.Header.Set("User-Agent", "claude-cli/1.0.0")
  139. req.Header.Set("X-App", "claude-code")
  140. req.Header.Set("anthropic-beta", "beta")
  141. req.Header.Set("anthropic-version", "2023-06-01")
  142. body := map[string]any{
  143. "model": "claude-sonnet-4",
  144. "system": []any{
  145. map[string]any{
  146. "type": "text",
  147. "text": "Generate JSON data for testing database migrations.",
  148. },
  149. },
  150. "metadata": map[string]any{
  151. "user_id": "user_" + "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + "_account__session_12345678-1234-1234-1234-123456789abc",
  152. },
  153. }
  154. result := v.Validate(req, body)
  155. require.False(t, result, "无关系统提示词应返回 false")
  156. }
  157. func TestValidate_MaxTokensOneHaikuBypass(t *testing.T) {
  158. v := newTestValidator()
  159. req := httptest.NewRequest("POST", "/v1/messages", nil)
  160. req.Header.Set("User-Agent", "claude-cli/1.0.0")
  161. // 不设置 X-App 等头,通过 context 标记为 haiku 探测请求
  162. ctx := context.WithValue(req.Context(), ctxkey.IsMaxTokensOneHaikuRequest, true)
  163. req = req.WithContext(ctx)
  164. // 即使 body 不包含 system prompt,也应通过
  165. result := v.Validate(req, map[string]any{"model": "claude-3-haiku", "max_tokens": 1})
  166. require.True(t, result, "max_tokens=1+haiku 探测请求应绕过严格验证")
  167. }
  168. func TestSystemPromptSimilarity(t *testing.T) {
  169. v := newTestValidator()
  170. tests := []struct {
  171. name string
  172. prompt string
  173. want bool
  174. }{
  175. {"精确匹配", "You are Claude Code, Anthropic's official CLI for Claude.", true},
  176. {"带多余空格", "You are Claude Code, Anthropic's official CLI for Claude.", true},
  177. {"Agent SDK 模板", "You are a Claude agent, built on Anthropic's Claude Agent SDK.", true},
  178. {"文件搜索专家模板", "You are a file search specialist for Claude Code, Anthropic's official CLI for Claude.", true},
  179. {"对话摘要模板", "You are a helpful AI assistant tasked with summarizing conversations.", true},
  180. {"交互式 CLI 模板", "You are an interactive CLI tool that helps users", true},
  181. {"无关文本", "Write me a poem about cats", false},
  182. {"空文本", "", false},
  183. }
  184. for _, tt := range tests {
  185. t.Run(tt.name, func(t *testing.T) {
  186. body := map[string]any{
  187. "model": "claude-sonnet-4",
  188. "system": []any{
  189. map[string]any{"type": "text", "text": tt.prompt},
  190. },
  191. }
  192. result := v.IncludesClaudeCodeSystemPrompt(body)
  193. require.Equal(t, tt.want, result, "提示词: %q", tt.prompt)
  194. })
  195. }
  196. }
  197. func TestDiceCoefficient(t *testing.T) {
  198. tests := []struct {
  199. name string
  200. a string
  201. b string
  202. want float64
  203. tol float64
  204. }{
  205. {"相同字符串", "hello", "hello", 1.0, 0.001},
  206. {"完全不同", "abc", "xyz", 0.0, 0.001},
  207. {"空字符串", "", "hello", 0.0, 0.001},
  208. {"单字符", "a", "b", 0.0, 0.001},
  209. }
  210. for _, tt := range tests {
  211. t.Run(tt.name, func(t *testing.T) {
  212. result := diceCoefficient(tt.a, tt.b)
  213. require.InDelta(t, tt.want, result, tt.tol)
  214. })
  215. }
  216. }
  217. func TestIsClaudeCodeClient_Context(t *testing.T) {
  218. ctx := context.Background()
  219. // 默认应为 false
  220. require.False(t, IsClaudeCodeClient(ctx))
  221. // 设置为 true
  222. ctx = SetClaudeCodeClient(ctx, true)
  223. require.True(t, IsClaudeCodeClient(ctx))
  224. // 设置为 false
  225. ctx = SetClaudeCodeClient(ctx, false)
  226. require.False(t, IsClaudeCodeClient(ctx))
  227. }
  228. func TestValidate_NilBody_MessagesPath(t *testing.T) {
  229. v := newTestValidator()
  230. req := httptest.NewRequest("POST", "/v1/messages", nil)
  231. req.Header.Set("User-Agent", "claude-cli/1.0.0")
  232. req.Header.Set("X-App", "claude-code")
  233. req.Header.Set("anthropic-beta", "beta")
  234. req.Header.Set("anthropic-version", "2023-06-01")
  235. result := v.Validate(req, nil)
  236. require.False(t, result, "nil body 的 messages 请求应返回 false")
  237. }