Files
AskaEth a1c9da6e8a test(voice/tts): pin fragment-before-query in redactURL fallback
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.
2026-10-26 22:11:01 +08:00

164 lines
7.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
})
}
}