package model // 本文件是 M52 的出口回归探针:model 两个适配器里所有「把 net/http 的 // *url.Error 直接打进错误串」的地方,一律先过 trace.URLCause。 // // 为什么要测:`client.Do` 失败时 net/http 会包出 // `Post "https://user:***@h/p?access_token=…": dial tcp …` —— 密码被打码,但 // **用户名与整条 query(含 access_token / 预签名参数)原样在串里**,而这条串会进日志、 // 进工单。`http.NewRequest` 的解析失败同样是 *url.Error,把整条 base URL 带出来。 // // 断言形状是「把任一处改回 err ⇒ 对应用例变红」:两个适配器 × 两条路(建请求 / // 发送)各一个子用例,不复用同一条断言链,因此变异能定位到具体那一处。 // 同时钉住「只少地址、不少原因」:底层原因(connection refused)与哨兵 ErrUpstream // 都必须还在 —— 这批只许去掉地址,不许把排障信息一起删。 import ( "context" "errors" "net/http" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) const ( // urlCauseQuerySecret 是藏在 query 里的凭据。net/http **不会**打码 query, // 因此它是这批病害最真实的载体。 urlCauseQuerySecret = "SECRET-2f8a1c" // urlCauseUser 是 userinfo 里的用户名(密码由 net/http 打码成 ***, // 因此用例只对用户名与 query 值断言 —— 那两段才是真正会漏的)。 urlCauseUser = "svc-user" // urlCauseBadHost 里的空格让 url.Parse 失败 ⇒ 「建请求」那条路的入口。 urlCauseBadHost = "bad host.example.com" ) // urlCauseBadBase 是「建请求」路径的 base URL:解析必然失败,且原文里同时带着 // userinfo 与 query 凭据。 func urlCauseBadBase() string { return "https://" + urlCauseUser + ":svc-pass@" + urlCauseBadHost + "/v1?access_token=" + urlCauseQuerySecret } // urlCauseGoodBase 是「发送」路径的 base URL:能解析、能建请求,失败由 transport 造成。 func urlCauseGoodBase() string { return "https://" + urlCauseUser + ":svc-pass@api.example.com/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") } // urlCauseFailClient 是一个只回传输错误的 client。 func urlCauseFailClient() *http.Client { return &http.Client{Transport: urlCauseFailTransport{}} } // assertNoURLCarried 断言错误串里既没有 query 凭据也没有用户名,但底层原因还在。 func assertNoURLCarried(t *testing.T, err error, sentinel error) { t.Helper() if err == nil { t.Fatal("want error, got nil") } if strings.Contains(err.Error(), urlCauseQuerySecret) { t.Fatalf("错误串带出了 query 里的凭据:%v", err) } if strings.Contains(err.Error(), urlCauseUser) { t.Fatalf("错误串带出了 userinfo 里的用户名:%v", err) } if !strings.Contains(err.Error(), "connection refused") { t.Fatalf("错误串丢了底层原因(只许少地址):%v", err) } if !errors.Is(err, sentinel) { t.Fatalf("err = %v, want errors.Is(%v)", err, sentinel) } } // assertNoURLCarriedInBuild 是「建请求」路径的同口径断言:那一条路的底层原因是 // url.Parse 的解析错误,因此存在性断言换成解析错误文本。 func assertNoURLCarriedInBuild(t *testing.T, err error, sentinel error) { t.Helper() 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 !errors.Is(err, sentinel) { t.Fatalf("err = %v, want errors.Is(%v)", err, sentinel) } } // TestOpenAIChatDropsURLFromTransportError 钉住 OpenAIChatAdapter.doRequest 那一处 // (`client.Do` 失败)。 func TestOpenAIChatDropsURLFromTransportError(t *testing.T) { t.Parallel() ad := NewOpenAIChat( config.LLMConfig{BaseURL: urlCauseGoodBase(), Model: "m"}, WithHTTPClient(urlCauseFailClient()), ) _, err := ad.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{}) assertNoURLCarried(t, err, ErrUpstream) } // TestOpenAIChatDropsURLFromBuildRequestError 钉住 OpenAIChatAdapter.Chat 里 // NewRequest 失败那一处。 func TestOpenAIChatDropsURLFromBuildRequestError(t *testing.T) { t.Parallel() ad := NewOpenAIChat(config.LLMConfig{BaseURL: urlCauseBadBase(), Model: "m"}) _, err := ad.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{}) assertNoURLCarriedInBuild(t, err, ErrUpstream) } // TestOpenAIChatStreamDropsURLFromBuildRequestError 钉住 ChatStream 里的同一个 // 同族点(两条路各写了一次,改一处漏一处同样会漏地址)。 func TestOpenAIChatStreamDropsURLFromBuildRequestError(t *testing.T) { t.Parallel() ad := NewOpenAIChat(config.LLMConfig{BaseURL: urlCauseBadBase(), Model: "m"}) _, err := ad.ChatStream(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, func(StreamEvent) {}) assertNoURLCarriedInBuild(t, err, ErrUpstream) } // TestOpenAIChatStreamDropsURLFromTransportError 钉住 ChatStream 复用 doRequest 的那条路。 func TestOpenAIChatStreamDropsURLFromTransportError(t *testing.T) { t.Parallel() ad := NewOpenAIChat( config.LLMConfig{BaseURL: urlCauseGoodBase(), Model: "m"}, WithHTTPClient(urlCauseFailClient()), ) _, err := ad.ChatStream(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, func(StreamEvent) {}) assertNoURLCarried(t, err, ErrUpstream) } // TestAnthropicChatDropsURLFromTransportError 钉住 AnthropicChatAdapter.doRequest。 func TestAnthropicChatDropsURLFromTransportError(t *testing.T) { t.Parallel() ad := NewAnthropicChat( config.LLMConfig{AnthropicBaseURL: urlCauseGoodBase(), AnthropicModel: "m", AnthropicAPIKey: "k"}, WithAnthropicHTTPClient(urlCauseFailClient()), ) _, err := ad.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{}) assertNoURLCarried(t, err, ErrUpstream) } // TestAnthropicChatDropsURLFromBuildRequestError 钉住 chatOnce 里的 NewRequest 失败。 func TestAnthropicChatDropsURLFromBuildRequestError(t *testing.T) { t.Parallel() ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: urlCauseBadBase(), AnthropicModel: "m", AnthropicAPIKey: "k"}) _, err := ad.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{}) assertNoURLCarriedInBuild(t, err, ErrUpstream) } // TestAnthropicChatStreamDropsURLFromBuildRequestError 钉住 streamOnce 里的同族点。 func TestAnthropicChatStreamDropsURLFromBuildRequestError(t *testing.T) { t.Parallel() ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: urlCauseBadBase(), AnthropicModel: "m", AnthropicAPIKey: "k"}) _, err := ad.ChatStream(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, func(StreamEvent) {}) assertNoURLCarriedInBuild(t, err, ErrUpstream) }