package voice // 本文件是 M52 的出口回归探针:voice 内置的两条 HTTP 链(cloud 的 postJSON、 // DashScope 的 dashscopePost / download)里「把 net/http 的 *url.Error 直接打进 // 错误串」的 6 处,一律先过 trace.URLCause。 // // 最要紧的是 DashScope 的 `download`:那是**预签名 OSS 地址**,签名与 STS 令牌就在 // query 里。`client.Do` 失败时 net/http 会把整条地址渲染进错误串,于是「下载失败」 // 这条最常见的报错就等于把一小时有效的签名写进日志。因此这一处单独给了用例。 // // 断言形状是「把任一处改回 err ⇒ 对应用例变红」:6 处各一个用例,全部同时钉住 // 「只少地址、不少原因」(底层原因与 ErrProviderFailed 哨兵必须还在)。 import ( "context" "errors" "io" "net/http" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) const ( urlCauseQuerySecret = "SECRET-2f8a1c" urlCauseUser = "svc-user" urlCauseBadHost = "bad host.example.com" ) // urlCausePresigned 是一条**带签名**的 OSS 地址:签名、STS 令牌、AccessKeyId 全在 // query 里。它们一旦进错误串/日志,就等于交付了一份可用的下载授权。 const urlCausePresigned = "https://oss-cn-hangzhou.aliyuncs.com/out.wav" + "?Expires=1700000000&OSSAccessKeyId=AKIA-SENTINEL&Signature=SIG-SENTINEL-9f2c&security-token=STS-SENTINEL-4b1" var ( urlCausePresignSecrets = []string{"SIG-SENTINEL-9f2c", "STS-SENTINEL-4b1", "AKIA-SENTINEL"} ) func voiceURLCauseGoodBase() string { return "https://" + urlCauseUser + ":svc-pass@voice.example.com/v1?access_token=" + urlCauseQuerySecret } func voiceURLCauseBadBase() string { return "https://" + urlCauseUser + ":svc-pass@" + urlCauseBadHost + "/v1?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:443: connect: connection refused") } func urlCauseFailClient() *http.Client { return &http.Client{Transport: urlCauseFailTransport{}} } // urlCauseStatusTransport 恒定回一个非 200 的响应(带一个可读的错误体)。 func urlCauseStatusTransport(status int) *http.Client { return &http.Client{Transport: urlCauseRoundTrip(func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: status, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"message":"denied"}`)), }, nil })} } // urlCauseRoundTrip 把函数适配成 http.RoundTripper。 type urlCauseRoundTrip func(*http.Request) (*http.Response, error) func (f urlCauseRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } // assertNoURLKept 断言错误串不含给定凭据、但含给定原因,且哨兵仍成立。 func assertNoURLKept(t *testing.T, err error, secrets []string, reason string) { t.Helper() if err == nil { t.Fatal("want error, got nil") } for _, s := range secrets { if strings.Contains(err.Error(), s) { t.Fatalf("错误串带出了凭据 %q:%v", s, err) } } if strings.Contains(err.Error(), urlCauseUser) { t.Fatalf("错误串带出了 userinfo 里的用户名:%v", err) } if !strings.Contains(err.Error(), reason) { t.Fatalf("错误串丢了底层原因(只许少地址):%v", err) } if !errors.Is(err, ErrProviderFailed) { t.Fatalf("err = %v, want ErrProviderFailed(分类/降级判据不许被带偏)", err) } } // TestHTTPTTSDropsURLFromTransportError 钉住 cloud 的 postJSON 里 `client.Do` 那一处。 func TestHTTPTTSDropsURLFromTransportError(t *testing.T) { t.Parallel() tts := NewHTTPTTS(voiceURLCauseGoodBase(), "k", WithHTTPClient(urlCauseFailClient())) _, err := tts.TTS(context.Background(), "hi") assertNoURLKept(t, err, []string{urlCauseQuerySecret}, "connection refused") if !errors.Is(err, ErrProviderFailed) { t.Fatalf("err = %v, want ErrProviderFailed", err) } } // TestHTTPSTTDropsURLFromBuildRequestError 钉住 cloud 的 postJSON 里 NewRequest 那一处。 func TestHTTPSTTDropsURLFromBuildRequestError(t *testing.T) { t.Parallel() stt := NewHTTPSTT(voiceURLCauseBadBase(), "k", WithHTTPClient(urlCauseFailClient())) _, err := stt.STT(context.Background(), Audio{Format: "wav", Data: []byte("x")}) assertNoURLKept(t, err, []string{urlCauseQuerySecret}, "invalid character") } // TestDashscopeDownloadDropsPresignedURLFromTransportError 钉住 DashScope 的 // `download`:预签名 OSS 地址在传输失败时**整条**不进错误串。 func TestDashscopeDownloadDropsPresignedURLFromTransportError(t *testing.T) { t.Parallel() tts := NewDashscopeTTS(config.VoiceConfig{DashscopeAPIKey: "k"}) tts.client = urlCauseFailClient() _, err := tts.download(context.Background(), urlCausePresigned) assertNoURLKept(t, err, urlCausePresignSecrets, "connection refused") // 地址的 host 段同样不该出现:这条串会进日志,而"哪个 OSS 域名"本身不是排障必需, // 真正的排障线索(连接被拒 / 超时)与 ErrProviderFailed 分类都还在。 if strings.Contains(err.Error(), "oss-cn-hangzhou") { t.Fatalf("错误串带出了预签名地址的 host:%v", err) } } // TestDashscopeDownloadDropsPresignedURLFromBuildRequestError 钉住 `download` 里 // NewRequest 那一处(畸形地址仍会回 *url.Error,同样是整条地址)。 func TestDashscopeDownloadDropsPresignedURLFromBuildRequestError(t *testing.T) { t.Parallel() tts := NewDashscopeTTS(config.VoiceConfig{DashscopeAPIKey: "k"}) tts.client = urlCauseFailClient() _, err := tts.download(context.Background(), "https://"+urlCauseUser+":svc-pass@"+urlCauseBadHost+"/out.wav?Signature=SIG-SENTINEL-9f2c") assertNoURLKept(t, err, []string{"SIG-SENTINEL-9f2c"}, "invalid character") } // TestDashscopeDownloadKeepsStatus 钉住「只少地址、不少状态」:被拒时状态码仍在错误串里。 func TestDashscopeDownloadKeepsStatus(t *testing.T) { t.Parallel() tts := NewDashscopeTTS(config.VoiceConfig{DashscopeAPIKey: "k"}) tts.client = urlCauseStatusTransport(http.StatusForbidden) _, err := tts.download(context.Background(), urlCausePresigned) if err == nil { t.Fatal("want error, got nil") } if !strings.Contains(err.Error(), "403") { t.Fatalf("错误串丢了状态码(排障会变盲):%v", err) } for _, s := range urlCausePresignSecrets { if strings.Contains(err.Error(), s) { t.Fatalf("错误串带出了凭据 %q:%v", s, err) } } } // TestDashscopePostDropsURLFromTransportError 钉住 dashscopePost 里 `client.Do` 那一处。 func TestDashscopePostDropsURLFromTransportError(t *testing.T) { t.Parallel() _, err := dashscopePost(context.Background(), urlCauseFailClient(), "k", voiceURLCauseGoodBase(), []byte(`{}`), "") assertNoURLKept(t, err, []string{urlCauseQuerySecret}, "connection refused") } // TestDashscopePostDropsURLFromBuildRequestError 钉住 dashscopePost 里 NewRequest 那一处。 func TestDashscopePostDropsURLFromBuildRequestError(t *testing.T) { t.Parallel() _, err := dashscopePost(context.Background(), urlCauseFailClient(), "k", voiceURLCauseBadBase(), []byte(`{}`), "") assertNoURLKept(t, err, []string{urlCauseQuerySecret}, "invalid character") }