Review r1 P2-01: the only fragment case had '?' before '#', so degrading stripURLQuery to cut '?' alone stayed green and the '#'-first branch was unpinned. Add a scheme-less '#access_token=...' case (OAuth implicit flow shape) and correct the mutation comment to state which case each mutation actually kills.
164 lines
7.0 KiB
Go
164 lines
7.0 KiB
Go
package tts
|
||
|
||
// 本文件锁定**日志侧**的两条口径(复审 r1 的 P2-2 / nit,都是"CI 抓不到"的那类):
|
||
//
|
||
// 1. 🔴 WARN / 错误链里**绝不能出现她的正文**。上游(FastAPI)的 422 会把请求体
|
||
// 里的 `input` 整段回显,所以「把错误体前 512 字节打进日志」= 把她的原话写进
|
||
// 日志。这里用一台**回显正文**的假上游 + 一个接日志缓冲区的 logger 来抓它。
|
||
// 2. `redactURL` 的兜底路径:地址里含 `@`(userinfo)时必须整体打码,哪怕 URL
|
||
// 本身不合法 —— 否则 `user:pass@` 会把凭据原样写进日志;而**漏了 scheme** 的
|
||
// 地址(`127.0.0.1:9880/v1?token=…`)走的是同一条兜底,query 必须被切掉。
|
||
|
||
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@", "<redacted>"},
|
||
{"无 scheme 含 @ ⇒ 整体打码", "user:pass@host", "<redacted>"},
|
||
{"空串原样", "", ""},
|
||
}
|
||
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)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestRedactURLNoSchemeDropsQuery 钉住 M55 的缺口:**漏了 scheme** 的地址
|
||
// (`127.0.0.1:9880/v1?token=…`)在 `url.Parse` 里是错误 —— 首段带 `:` ——
|
||
// 于是 host 为空、走兜底。旧兜底把原串连 query 一起返回,签名/token 就这么进了
|
||
// 日志与工单。现在兜底只按 `?`/`#` 切掉查询串与片段。
|
||
//
|
||
// 变异(两种,各自被不同的子用例杀灭):
|
||
// - 兜底改回 `raw` ⇒ 带 query 的三条用例全红(secret / `token=` 会出现在结果里);
|
||
// - `stripURLQuery` 退化成**只切 `?`** ⇒ 只有「片段先于查询」那条红
|
||
// (`#access_token=…` 原样留在结果里)。「query 与片段」那条**打不红** ——
|
||
// `?` 在 `#` 之前,只切 `?` 会顺带把片段带走 —— 所以 `#` 先行这条分支必须由
|
||
// 「片段先于查询」独立钉住(review M55 r1 的 P2-01)。
|
||
func TestRedactURLNoSchemeDropsQuery(t *testing.T) {
|
||
const secret = "TTS-SECRET-7b3e"
|
||
cases := []struct {
|
||
name string
|
||
in string
|
||
want string
|
||
}{
|
||
{"无 scheme 带 query", "127.0.0.1:9880/v1/audio/speech?token=" + secret, "127.0.0.1:9880/v1/audio/speech"},
|
||
{"无 scheme 带 query 与片段", "tts.internal:9880/v1?token=" + secret + "#frag", "tts.internal:9880/v1"},
|
||
{"无 scheme 片段先于查询", "127.0.0.1:9880/v1#access_token=" + secret, "127.0.0.1:9880/v1"},
|
||
{"无 scheme 无 query 原样", "127.0.0.1:9880", "127.0.0.1:9880"},
|
||
{"无 scheme 带 userinfo ⇒ 整体打码", "user:pass@host/v1?token=" + secret, "<redacted>"},
|
||
}
|
||
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)
|
||
}
|
||
for _, leak := range []string{secret, "token=", "pass"} {
|
||
if strings.Contains(got, leak) {
|
||
t.Fatalf("可打日志的地址里出现了 %q: %q", leak, got)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|