package tts // 本文件是 M52 的出口回归探针:emotionTTS 客户端里两条会把整条 endpoint 带出去的路 // ——`speak` 的建请求失败、`unreachable` 的传输失败 —— 都先过 trace.URLCause。 // // 这一处比别处更绕:日志行早就用 `redactURL(endpoint)` 脱敏了,但**同一个 err 还被 // 同一行当作 `"err", err` 打出去**,而返回值又把它 `%w` 包着带回去 ⇒ 日志侧那道脱敏 // 等于被绕过(地址从 err 字段原样出现)。所以这里的断言是双面的: // - 错误串里没有地址; // - 日志里那条地址**只出现一次**(脱敏那次),不出现第二遍原串。 // // 断言形状是「把任一处改回 err ⇒ 对应用例变红」:两处各一个用例。 import ( "bytes" "context" "errors" "log/slog" "net/http" "strings" "testing" ) const ( urlCauseQuerySecret = "SECRET-2f8a1c" urlCauseUser = "svc-user" urlCauseBadHost = "bad host.example.com" ) func ttsURLCauseGoodBase() string { return "https://" + urlCauseUser + ":svc-pass@tts.example.com?access_token=" + urlCauseQuerySecret } func ttsURLCauseBadBase() string { return "https://" + urlCauseUser + ":svc-pass@" + urlCauseBadHost + "?access_token=" + urlCauseQuerySecret } // urlCauseFailTransport 让任何请求都在传输层失败(不经网络)。 type urlCauseFailTransport struct{} func (urlCauseFailTransport) RoundTrip(*http.Request) (*http.Response, error) { return nil, errors.New("dial tcp 10.0.0.1:9880: connect: connection refused") } // TestUnreachableDropsURLFromErrorAndLog 钉住 unreachable:错误串与日志都只留原因, // 地址(含 query 凭据与用户名)一个字节都不出现,且日志里那条地址只出现一次。 func TestUnreachableDropsURLFromErrorAndLog(t *testing.T) { t.Parallel() var logBuf bytes.Buffer c := New( Config{Enabled: true, BaseURL: ttsURLCauseGoodBase()}, WithHTTPClient(&http.Client{Transport: urlCauseFailTransport{}}), WithLogger(slog.New(slog.NewJSONHandler(&logBuf, nil))), ) _, err := c.Speech(context.Background(), "char_1", "hi") if err == nil { t.Fatal("want error, got nil") } if !errors.Is(err, ErrUnreachable) { t.Fatalf("err = %v, want ErrUnreachable(分类不许被带偏)", err) } if !strings.Contains(err.Error(), "connection refused") { t.Fatalf("错误串丢了底层原因(只许少地址):%v", err) } for _, surface := range []string{err.Error(), logBuf.String()} { if strings.Contains(surface, urlCauseQuerySecret) { t.Fatalf("query 里的凭据进了错误或日志:%s", surface) } if strings.Contains(surface, urlCauseUser) { t.Fatalf("userinfo 里的用户名进了错误或日志:%s", surface) } } // 「日志里不会出现两遍地址」:脱敏后的 host 只该由 endpoint 字段出现一次。 if got := strings.Count(logBuf.String(), "tts.example.com"); got != 1 { t.Fatalf("日志里 host 出现 %d 次,want 1(endpoint 字段那次):%s", got, logBuf.String()) } if strings.Contains(err.Error(), "tts.example.com") { t.Fatalf("错误串带出了 endpoint 的 host:%v", err) } } // TestSpeakDropsURLFromBuildRequestError 钉住 speak 里 NewRequest 失败那一处: // 畸形 base URL 的解析错误同样是 *url.Error,会把整条地址带出来。 func TestSpeakDropsURLFromBuildRequestError(t *testing.T) { t.Parallel() var logBuf bytes.Buffer c := New( Config{Enabled: true, BaseURL: ttsURLCauseBadBase()}, WithHTTPClient(&http.Client{Transport: urlCauseFailTransport{}}), WithLogger(slog.New(slog.NewJSONHandler(&logBuf, nil))), ) _, err := c.Speech(context.Background(), "char_1", "hi") if err == nil { t.Fatal("want error, got nil") } if strings.Contains(err.Error(), urlCauseQuerySecret) || strings.Contains(err.Error(), urlCauseUser) { t.Fatalf("建请求失败的串带出了 base URL 里的凭据:%v", err) } if !strings.Contains(err.Error(), "invalid character") { t.Fatalf("错误串丢了底层解析原因(只许少地址):%v", err) } if strings.Contains(logBuf.String(), urlCauseQuerySecret) { t.Fatalf("建请求失败也把地址写进了日志:%s", logBuf.String()) } }