channel_monitor_checker_body_test.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "encoding/json"
  6. "net/http"
  7. "net/http/httptest"
  8. "regexp"
  9. "strconv"
  10. "strings"
  11. "testing"
  12. "time"
  13. )
  14. // swapMonitorHTTPClient 临时替换 monitorHTTPClient 为不带 SSRF 校验的普通 client,
  15. // 让 httptest (127.0.0.1) 能连通。测试结束后恢复。
  16. func swapMonitorHTTPClient(t *testing.T) {
  17. t.Helper()
  18. orig := monitorHTTPClient
  19. monitorHTTPClient = &http.Client{Timeout: 5 * time.Second}
  20. t.Cleanup(func() { monitorHTTPClient = orig })
  21. }
  22. // captureHandler 把每次收到的请求 body 和 headers 存起来,测试断言用。
  23. type captureHandler struct {
  24. lastBody map[string]any
  25. lastHeaders http.Header
  26. respondText string // 写到 Anthropic content[0].text 里(校验用)
  27. status int
  28. }
  29. func (h *captureHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  30. h.lastHeaders = r.Header.Clone()
  31. defer func() { _ = r.Body.Close() }()
  32. var parsed map[string]any
  33. _ = json.NewDecoder(r.Body).Decode(&parsed)
  34. h.lastBody = parsed
  35. if h.status == 0 {
  36. h.status = 200
  37. }
  38. w.Header().Set("Content-Type", "application/json")
  39. w.WriteHeader(h.status)
  40. // 构造 Anthropic 格式的响应:content[0].text = h.respondText
  41. _ = json.NewEncoder(w).Encode(map[string]any{
  42. "content": []map[string]any{
  43. {"type": "text", "text": h.respondText},
  44. },
  45. })
  46. }
  47. func setupFakeAnthropic(t *testing.T, handler *captureHandler) string {
  48. t.Helper()
  49. swapMonitorHTTPClient(t)
  50. srv := httptest.NewServer(handler)
  51. t.Cleanup(srv.Close)
  52. return srv.URL
  53. }
  54. type openAICaptureHandler struct {
  55. lastBody map[string]any
  56. lastHeaders http.Header
  57. lastPath string
  58. status int
  59. rawResponse string
  60. responsesLeadingReasoning bool
  61. }
  62. func (h *openAICaptureHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  63. h.lastHeaders = r.Header.Clone()
  64. h.lastPath = r.URL.Path
  65. defer func() { _ = r.Body.Close() }()
  66. var parsed map[string]any
  67. _ = json.NewDecoder(r.Body).Decode(&parsed)
  68. h.lastBody = parsed
  69. if h.status == 0 {
  70. h.status = http.StatusOK
  71. }
  72. w.Header().Set("Content-Type", "application/json")
  73. w.WriteHeader(h.status)
  74. if h.rawResponse != "" {
  75. _, _ = w.Write([]byte(h.rawResponse))
  76. return
  77. }
  78. answer := answerFromOpenAIRequest(parsed)
  79. if h.lastPath == providerOpenAIResponsesPath {
  80. output := []map[string]any{}
  81. if h.responsesLeadingReasoning {
  82. output = append(output, map[string]any{
  83. "type": "reasoning",
  84. "summary": []any{},
  85. })
  86. }
  87. output = append(output, map[string]any{
  88. "type": "message",
  89. "status": "completed",
  90. "role": "assistant",
  91. "content": []map[string]any{
  92. {"type": "output_text", "text": answer},
  93. },
  94. })
  95. _ = json.NewEncoder(w).Encode(map[string]any{
  96. "output": output,
  97. })
  98. return
  99. }
  100. _ = json.NewEncoder(w).Encode(map[string]any{
  101. "choices": []map[string]any{{"message": map[string]any{"content": answer}}},
  102. })
  103. }
  104. func setupFakeOpenAI(t *testing.T, handler *openAICaptureHandler) string {
  105. t.Helper()
  106. swapMonitorHTTPClient(t)
  107. srv := httptest.NewServer(handler)
  108. t.Cleanup(srv.Close)
  109. return srv.URL
  110. }
  111. func answerFromOpenAIRequest(body map[string]any) string {
  112. prompt, _ := body["input"].(string)
  113. if prompt == "" {
  114. if messages, ok := body["messages"].([]any); ok && len(messages) > 0 {
  115. if msg, ok := messages[0].(map[string]any); ok {
  116. prompt, _ = msg["content"].(string)
  117. }
  118. }
  119. }
  120. return answerFromChallengePrompt(prompt)
  121. }
  122. var challengeQuestionRegex = regexp.MustCompile(`Q: (\d+) ([+-]) (\d+) = \?\nA:$`)
  123. func answerFromChallengePrompt(prompt string) string {
  124. m := challengeQuestionRegex.FindStringSubmatch(prompt)
  125. if len(m) != 4 {
  126. return "0"
  127. }
  128. left, _ := strconv.Atoi(m[1])
  129. right, _ := strconv.Atoi(m[3])
  130. if m[2] == "+" {
  131. return strconv.Itoa(left + right)
  132. }
  133. return strconv.Itoa(left - right)
  134. }
  135. func TestRunCheckForModel_OffMode_PreservesDefaultBody(t *testing.T) {
  136. h := &captureHandler{respondText: "the answer is 42"}
  137. endpoint := setupFakeAnthropic(t, h)
  138. // 跑一次 off 模式(opts=nil),确认默认 body 行为未变
  139. _ = runCheckForModel(context.Background(), MonitorProviderAnthropic, endpoint, "sk-fake", "claude-x", nil)
  140. if h.lastBody["model"] != "claude-x" {
  141. t.Errorf("default body should contain model=claude-x, got %v", h.lastBody["model"])
  142. }
  143. if _, ok := h.lastBody["messages"]; !ok {
  144. t.Error("default body should contain messages")
  145. }
  146. if h.lastHeaders.Get("x-api-key") != "sk-fake" {
  147. t.Errorf("expected adapter's x-api-key header, got %q", h.lastHeaders.Get("x-api-key"))
  148. }
  149. }
  150. func TestRunCheckForModel_OpenAI_DefaultChatRequest(t *testing.T) {
  151. h := &openAICaptureHandler{}
  152. endpoint := setupFakeOpenAI(t, h)
  153. res := runCheckForModel(context.Background(), MonitorProviderOpenAI, endpoint, "sk-openai", "gpt-test", nil)
  154. if res.Status != MonitorStatusOperational {
  155. t.Fatalf("default chat request should pass challenge, got status=%s message=%q", res.Status, res.Message)
  156. }
  157. if h.lastPath != providerOpenAIPath {
  158. t.Fatalf("expected chat completions path %q, got %q", providerOpenAIPath, h.lastPath)
  159. }
  160. if h.lastBody["model"] != "gpt-test" {
  161. t.Errorf("chat body should contain model=gpt-test, got %v", h.lastBody["model"])
  162. }
  163. if _, ok := h.lastBody["messages"]; !ok {
  164. t.Error("chat body should contain messages")
  165. }
  166. if _, ok := h.lastBody["instructions"]; ok {
  167. t.Error("chat body must not contain top-level instructions")
  168. }
  169. if h.lastBody["stream"] != false {
  170. t.Errorf("chat body should set stream=false, got %v", h.lastBody["stream"])
  171. }
  172. if h.lastHeaders.Get("Authorization") != "Bearer sk-openai" {
  173. t.Errorf("expected bearer auth header, got %q", h.lastHeaders.Get("Authorization"))
  174. }
  175. }
  176. func TestGrokMonitorConfiguration(t *testing.T) {
  177. if err := validateProvider(MonitorProviderGrok); err != nil {
  178. t.Fatalf("grok provider should be supported: %v", err)
  179. }
  180. if got := normalizeMonitorPrimaryModel(MonitorProviderGrok, MonitorCheckModeProbe, ""); got != MonitorDefaultGrokModel {
  181. t.Fatalf("expected default Grok model %q, got %q", MonitorDefaultGrokModel, got)
  182. }
  183. if err := validateAPIMode(MonitorProviderGrok, MonitorAPIModeChatCompletions); err != nil {
  184. t.Fatalf("grok chat_completions mode should be valid: %v", err)
  185. }
  186. if err := validateAPIMode(MonitorProviderGrok, MonitorAPIModeResponses); err == nil {
  187. t.Fatal("grok responses mode should be rejected by channel monitoring")
  188. }
  189. if err := validateReplaceRequestBody(MonitorProviderGrok, MonitorAPIModeChatCompletions, map[string]any{}); err == nil {
  190. t.Fatal("grok replace-mode body should require messages")
  191. }
  192. }
  193. func TestRunCheckForModel_Grok_DefaultChatRequest(t *testing.T) {
  194. h := &openAICaptureHandler{}
  195. endpoint := setupFakeOpenAI(t, h)
  196. res := runCheckForModel(context.Background(), MonitorProviderGrok, endpoint, "xai-key", MonitorDefaultGrokModel, nil)
  197. if res.Status != MonitorStatusOperational {
  198. t.Fatalf("Grok request should pass challenge, got status=%s message=%q", res.Status, res.Message)
  199. }
  200. if res.LatencyMs == nil {
  201. t.Fatal("Grok request should record latency")
  202. }
  203. if h.lastPath != providerGrokPath {
  204. t.Fatalf("expected Grok chat completions path %q, got %q", providerGrokPath, h.lastPath)
  205. }
  206. if h.lastBody["model"] != MonitorDefaultGrokModel {
  207. t.Errorf("Grok body should contain model=%s, got %v", MonitorDefaultGrokModel, h.lastBody["model"])
  208. }
  209. if _, ok := h.lastBody["messages"]; !ok {
  210. t.Error("Grok body should contain messages")
  211. }
  212. if h.lastBody["stream"] != false {
  213. t.Errorf("Grok body should set stream=false, got %v", h.lastBody["stream"])
  214. }
  215. if h.lastHeaders.Get("Authorization") != "Bearer xai-key" {
  216. t.Errorf("expected Grok bearer auth header, got %q", h.lastHeaders.Get("Authorization"))
  217. }
  218. }
  219. func TestRunCheckForModel_Grok_UpstreamFailure(t *testing.T) {
  220. h := &openAICaptureHandler{status: http.StatusTooManyRequests}
  221. endpoint := setupFakeOpenAI(t, h)
  222. res := runCheckForModel(context.Background(), MonitorProviderGrok, endpoint, "xai-key", MonitorDefaultGrokModel, nil)
  223. if res.Status != MonitorStatusError {
  224. t.Fatalf("Grok 429 should be recorded as error, got status=%s message=%q", res.Status, res.Message)
  225. }
  226. if !strings.Contains(res.Message, "upstream HTTP 429") {
  227. t.Fatalf("Grok failure should preserve upstream status, got %q", res.Message)
  228. }
  229. if res.LatencyMs == nil {
  230. t.Fatal("Grok failure should still record latency")
  231. }
  232. }
  233. func TestRunCheckForModel_Grok_RedactsXAIKeyFromUpstreamBody(t *testing.T) {
  234. h := &openAICaptureHandler{
  235. status: http.StatusUnauthorized,
  236. rawResponse: `{"error":{"message":"invalid API key xai-secret"}}`,
  237. }
  238. endpoint := setupFakeOpenAI(t, h)
  239. res := runCheckForModel(context.Background(), MonitorProviderGrok, endpoint, "request-key", MonitorDefaultGrokModel, nil)
  240. if res.Status != MonitorStatusError {
  241. t.Fatalf("Grok upstream failure should be recorded as error, got %s", res.Status)
  242. }
  243. if strings.Contains(res.Message, "xai-secret") {
  244. t.Fatalf("Grok error message leaked xAI key: %q", res.Message)
  245. }
  246. if !strings.Contains(res.Message, "xai-***REDACTED***") {
  247. t.Fatalf("Grok error message should contain redaction marker, got %q", res.Message)
  248. }
  249. }
  250. func TestRunCheckForModel_OpenAIResponses_DefaultRequest(t *testing.T) {
  251. h := &openAICaptureHandler{}
  252. endpoint := setupFakeOpenAI(t, h)
  253. res := runCheckForModel(context.Background(), MonitorProviderOpenAI, endpoint, "sk-openai", "gpt-test", &CheckOptions{
  254. APIMode: MonitorAPIModeResponses,
  255. })
  256. if res.Status != MonitorStatusOperational {
  257. t.Fatalf("default responses request should pass challenge, got status=%s message=%q", res.Status, res.Message)
  258. }
  259. if h.lastPath != providerOpenAIResponsesPath {
  260. t.Fatalf("expected responses path %q, got %q", providerOpenAIResponsesPath, h.lastPath)
  261. }
  262. if h.lastBody["model"] != "gpt-test" {
  263. t.Errorf("responses body should contain model=gpt-test, got %v", h.lastBody["model"])
  264. }
  265. instructions, _ := h.lastBody["instructions"].(string)
  266. if strings.TrimSpace(instructions) == "" {
  267. t.Error("responses body should contain non-empty instructions")
  268. }
  269. input, _ := h.lastBody["input"].(string)
  270. if strings.TrimSpace(input) == "" {
  271. t.Error("responses body should contain non-empty input")
  272. }
  273. if _, ok := h.lastBody["messages"]; ok {
  274. t.Error("responses body must not contain chat messages")
  275. }
  276. if h.lastBody["stream"] != false {
  277. t.Errorf("responses body should set stream=false, got %v", h.lastBody["stream"])
  278. }
  279. if h.lastHeaders.Get("Authorization") != "Bearer sk-openai" {
  280. t.Errorf("expected bearer auth header, got %q", h.lastHeaders.Get("Authorization"))
  281. }
  282. }
  283. func TestRunCheckForModel_OpenAIResponses_SkipsLeadingReasoningItem(t *testing.T) {
  284. h := &openAICaptureHandler{responsesLeadingReasoning: true}
  285. endpoint := setupFakeOpenAI(t, h)
  286. res := runCheckForModel(context.Background(), MonitorProviderOpenAI, endpoint, "sk-openai", "gpt-5.5", &CheckOptions{
  287. APIMode: MonitorAPIModeResponses,
  288. })
  289. if res.Status != MonitorStatusOperational {
  290. t.Fatalf("responses request should find text after leading reasoning item, got status=%s message=%q", res.Status, res.Message)
  291. }
  292. if h.lastPath != providerOpenAIResponsesPath {
  293. t.Fatalf("expected responses path %q, got %q", providerOpenAIResponsesPath, h.lastPath)
  294. }
  295. }
  296. func TestRunCheckForModel_OpenAIResponsesReplaceMissingInstructionsFailsLocally(t *testing.T) {
  297. h := &openAICaptureHandler{}
  298. endpoint := setupFakeOpenAI(t, h)
  299. res := runCheckForModel(context.Background(), MonitorProviderOpenAI, endpoint, "sk-openai", "gpt-test", &CheckOptions{
  300. APIMode: MonitorAPIModeResponses,
  301. BodyOverrideMode: MonitorBodyOverrideModeReplace,
  302. BodyOverride: map[string]any{
  303. "model": "gpt-test",
  304. "input": "hello",
  305. },
  306. })
  307. if res.Status != MonitorStatusError {
  308. t.Fatalf("invalid responses replace body should fail locally as error, got status=%s", res.Status)
  309. }
  310. if !strings.Contains(res.Message, "instructions and input are required") {
  311. t.Errorf("expected local validation message about instructions/input, got %q", res.Message)
  312. }
  313. if h.lastPath != "" {
  314. t.Errorf("invalid replace body should fail before HTTP request, got path %q", h.lastPath)
  315. }
  316. }
  317. func TestRunCheckForModel_MergeMode_UserFieldsWinButDenyListProtects(t *testing.T) {
  318. h := &captureHandler{respondText: "the answer is 42"}
  319. endpoint := setupFakeAnthropic(t, h)
  320. opts := &CheckOptions{
  321. BodyOverrideMode: MonitorBodyOverrideModeMerge,
  322. BodyOverride: map[string]any{
  323. "system": "You are Claude Code...",
  324. "max_tokens": float64(999), // 应该覆盖默认 50
  325. "model": "hacked-model", // 应该被黑名单挡住,保留原 model
  326. "messages": []any{}, // 同上,被挡
  327. },
  328. ExtraHeaders: map[string]string{
  329. "User-Agent": "claude-cli/1.0",
  330. "Content-Length": "999", // 黑名单
  331. "x-custom": "ok",
  332. },
  333. }
  334. _ = runCheckForModel(context.Background(), MonitorProviderAnthropic, endpoint, "sk-fake", "claude-x", opts)
  335. if h.lastBody["system"] != "You are Claude Code..." {
  336. t.Errorf("merge mode should inject system, got %v", h.lastBody["system"])
  337. }
  338. // max_tokens 覆盖生效
  339. if mt, ok := h.lastBody["max_tokens"].(float64); !ok || mt != 999 {
  340. t.Errorf("merge mode should override max_tokens to 999, got %v", h.lastBody["max_tokens"])
  341. }
  342. // model 在黑名单 — 应该保留默认值
  343. if h.lastBody["model"] != "claude-x" {
  344. t.Errorf("model should be protected by deny list, got %v", h.lastBody["model"])
  345. }
  346. // messages 在黑名单 — 应该保留默认值(非空)
  347. msgs, _ := h.lastBody["messages"].([]any)
  348. if len(msgs) == 0 {
  349. t.Error("messages should be protected by deny list (kept default, non-empty)")
  350. }
  351. // header 合并
  352. if h.lastHeaders.Get("User-Agent") != "claude-cli/1.0" {
  353. t.Errorf("extra User-Agent should override, got %q", h.lastHeaders.Get("User-Agent"))
  354. }
  355. if h.lastHeaders.Get("x-custom") != "ok" {
  356. t.Errorf("extra custom header should be present, got %q", h.lastHeaders.Get("x-custom"))
  357. }
  358. // Content-Length 黑名单:会被 net/http 自动重算,但不应由用户的 "999" 决定。
  359. // 我们无法直接断言丢弃(http.Client 总会填上),只断言请求成功即可。
  360. }
  361. func TestRunCheckForModel_ReplaceMode_FullBodyUsedAndChallengeSkipped(t *testing.T) {
  362. // replace 模式下我们的 body 完全自定义,challenge 数学题不会出现在请求里,
  363. // 上游也不会回正确答案 — 但只要 2xx + 响应文本非空,就算 operational
  364. h := &captureHandler{respondText: "any non-empty text"}
  365. endpoint := setupFakeAnthropic(t, h)
  366. userBody := map[string]any{
  367. "model": "user-forced-model",
  368. "messages": []any{map[string]any{"role": "user", "content": "hi"}},
  369. "max_tokens": float64(10),
  370. "system": "You are someone else",
  371. }
  372. opts := &CheckOptions{
  373. BodyOverrideMode: MonitorBodyOverrideModeReplace,
  374. BodyOverride: userBody,
  375. }
  376. res := runCheckForModel(context.Background(), MonitorProviderAnthropic, endpoint, "sk-fake", "claude-x", opts)
  377. // 请求 body = 用户提供的原样
  378. if h.lastBody["model"] != "user-forced-model" {
  379. t.Errorf("replace mode should use user's model, got %v", h.lastBody["model"])
  380. }
  381. if h.lastBody["system"] != "You are someone else" {
  382. t.Errorf("replace mode should use user's system, got %v", h.lastBody["system"])
  383. }
  384. // challenge 虽然没命中,但由于 replace 模式跳过 challenge 校验 + 响应非空 → operational
  385. if res.Status != MonitorStatusOperational {
  386. t.Errorf("replace mode with 2xx + non-empty text should be operational, got status=%s message=%q",
  387. res.Status, res.Message)
  388. }
  389. }
  390. func TestRunCheckForModel_ReplaceMode_EmptyResponseIsFailed(t *testing.T) {
  391. h := &captureHandler{respondText: ""} // 上游 200 但 content[0].text 为空
  392. endpoint := setupFakeAnthropic(t, h)
  393. opts := &CheckOptions{
  394. BodyOverrideMode: MonitorBodyOverrideModeReplace,
  395. BodyOverride: map[string]any{"model": "x", "messages": []any{}},
  396. }
  397. res := runCheckForModel(context.Background(), MonitorProviderAnthropic, endpoint, "sk-fake", "claude-x", opts)
  398. if res.Status != MonitorStatusFailed {
  399. t.Errorf("replace mode with empty text should be failed, got status=%s", res.Status)
  400. }
  401. if !strings.Contains(res.Message, "replace-mode") {
  402. t.Errorf("failure message should hint replace-mode, got %q", res.Message)
  403. }
  404. }
  405. func TestExtractAnthropicMonitorText(t *testing.T) {
  406. tests := []struct {
  407. name string
  408. body string
  409. want string
  410. }{
  411. {
  412. name: "text block after thinking",
  413. body: `{"content":[{"type":"thinking","thinking":""},{"type":"text","text":"2"}]}`,
  414. want: "2",
  415. },
  416. {
  417. name: "single text block",
  418. body: `{"content":[{"type":"text","text":"2"}]}`,
  419. want: "2",
  420. },
  421. {
  422. name: "thinking only",
  423. body: `{"content":[{"type":"thinking","thinking":""}]}`,
  424. want: "",
  425. },
  426. {
  427. name: "multiple text blocks",
  428. body: `{"content":[{"type":"text","text":"answer"},{"type":"tool_use","name":"x"},{"type":"text","text":"2"}]}`,
  429. want: "answer\n2",
  430. },
  431. }
  432. for _, tt := range tests {
  433. t.Run(tt.name, func(t *testing.T) {
  434. got := extractAnthropicMonitorText([]byte(tt.body))
  435. if got != tt.want {
  436. t.Fatalf("extractAnthropicMonitorText() = %q, want %q", got, tt.want)
  437. }
  438. })
  439. }
  440. }
  441. func TestValidateChallenge_AnthropicTextAfterThinking(t *testing.T) {
  442. body := []byte(`{"content":[{"type":"thinking","thinking":""},{"type":"text","text":"答案是 2"}]}`)
  443. respText := extractAnthropicMonitorText(body)
  444. if !validateChallenge(respText, "2") {
  445. t.Fatalf("validateChallenge(%q, %q) = false, want true", respText, "2")
  446. }
  447. }
  448. func TestGeminiMonitorBodyIncludesExplicitUserRole(t *testing.T) {
  449. adapter := providerAdapters[MonitorProviderGemini]
  450. body, err := adapter.buildBody("gemini-3.6-flash", "Reply with only 7.")
  451. if err != nil {
  452. t.Fatalf("buildBody() error = %v", err)
  453. }
  454. var payload struct {
  455. Contents []struct {
  456. Role string `json:"role"`
  457. } `json:"contents"`
  458. }
  459. if err := json.Unmarshal(body, &payload); err != nil {
  460. t.Fatalf("Unmarshal() error = %v", err)
  461. }
  462. if len(payload.Contents) != 1 {
  463. t.Fatalf("contents length = %d, want 1", len(payload.Contents))
  464. }
  465. if payload.Contents[0].Role != "user" {
  466. t.Fatalf("contents[0].role = %q, want user", payload.Contents[0].Role)
  467. }
  468. }