package tts // 本文件锁定**日志侧**的两条口径(复审 r1 的 P2-2 / nit,都是"CI 抓不到"的那类): // // 1. 🔴 WARN / 错误链里**绝不能出现她的正文**。上游(FastAPI)的 422 会把请求体 // 里的 `input` 整段回显,所以「把错误体前 512 字节打进日志」= 把她的原话写进 // 日志。这里用一台**回显正文**的假上游 + 一个接日志缓冲区的 logger 来抓它。 // 2. `redactURL` 的兜底路径:地址里含 `@`(userinfo)时必须整体打码,哪怕 URL // 本身不合法 —— 否则 `user:pass@` 会把凭据原样写进日志。 import ( "bytes" "context" "errors" "log/slog" "net/http" "net/http/httptest" "strings" "testing" ) // captureLogger 返回一个把日志写进内存缓冲的 logger(断言用)。 func captureLogger() (*slog.Logger, *bytes.Buffer) { var buf bytes.Buffer return slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), &buf } // TestSpeechRejectLogOmitsRequestText 是隐私红线的用例:上游回 422 并**回显请求体** // (FastAPI 的真实行为),日志里因此**一个正文词都不许出现**,只许有元数据。 // // 变异:把错误体重新塞回 WARN(`"detail", string(snippet)`)⇒ 本用例必红。 func TestSpeechRejectLogOmitsRequestText(t *testing.T) { log, buf := captureLogger() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnprocessableEntity) // 这就是复审在真服务上抓到的形状:input 被整段回显。 _, _ = w.Write([]byte(`{"detail":[{"type":"missing","loc":["body","voice"],` + `"msg":"Field required","input":{"model":"emotionTTS","input":"` + chineseSpeech + `"}}]}`)) })) t.Cleanup(srv.Close) client := New(Config{Enabled: true, BaseURL: srv.URL}, WithLogger(log)) _, err := client.Speech(context.Background(), DefaultCharacter, chineseSpeech) if !errors.Is(err, ErrUpstream) { t.Fatalf("err = %v, want ErrUpstream", err) } logs := buf.String() // ① 日志里不得出现正文的任何一段(整句、半句、单个关键词都算泄漏)。 for _, leak := range []string{chineseSpeech, "苏梦", "心情特别好", "你好"} { if strings.Contains(logs, leak) { t.Fatalf("日志里出现了请求正文(%q)—— 隐私红线:\n%s", leak, logs) } } // ② 也不得出现上游错误体的结构键(那正是"把 body 打出来"的痕迹)。 if strings.Contains(logs, "detail") || strings.Contains(logs, "loc") { t.Fatalf("日志里出现了上游错误体内容:\n%s", logs) } // ③ 该有的元数据必须在:状态码 + 响应字节数(排查靠它们,不靠正文)。 if !strings.Contains(logs, "status=422") { t.Fatalf("日志缺状态码:\n%s", logs) } if !strings.Contains(logs, "response_bytes=") { t.Fatalf("日志缺响应字节数:\n%s", logs) } // ④ 错误串本身也不许带正文(调用方可能把它写日志)。 if strings.Contains(err.Error(), "苏梦") || strings.Contains(err.Error(), chineseSpeech) { t.Fatalf("错误串里出现了正文: %v", err) } } // TestSpeechRejectLogUsesContentLength 钉住"字节数优先取 Content-Length": // 有长度时不必读响应体就能报出大小(不为数字节把整段读完)。 func TestSpeechRejectLogUsesContentLength(t *testing.T) { log, buf := captureLogger() body := `{"detail":"nope"}` srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Length", "17") w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(body)) })) t.Cleanup(srv.Close) client := New(Config{Enabled: true, BaseURL: srv.URL}, WithLogger(log)) if _, err := client.Speech(context.Background(), DefaultCharacter, "你好"); !errors.Is(err, ErrUpstream) { t.Fatalf("err = %v, want ErrUpstream", err) } logs := buf.String() if !strings.Contains(logs, "response_bytes=17") { t.Fatalf("没有采用 Content-Length: 17:\n%s", logs) } if strings.Contains(logs, "nope") { t.Fatalf("日志里出现了响应体内容:\n%s", logs) } } // TestRedactURLCredentials 钉住 `redactURL` 的凭据兜底(复审 r1 的 nit)。 func TestRedactURLCredentials(t *testing.T) { cases := []struct { name string in string want string }{ {"普通地址不动", "http://127.0.0.1:9880", "http://127.0.0.1:9880"}, {"合法 URL 的 userinfo 被丢掉", "http://user:pass@127.0.0.1:9880", "http://127.0.0.1:9880"}, {"非法 URL 含 @ ⇒ 整体打码", "http://user:pass@", ""}, {"无 scheme 含 @ ⇒ 整体打码", "user:pass@host", ""}, {"空串原样", "", ""}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := redactURL(c.in) if got != c.want { t.Fatalf("redactURL(%q) = %q, want %q", c.in, got, c.want) } if strings.Contains(got, "pass") { t.Fatalf("凭据漏进可打日志的地址: %q", got) } }) } }