Files
Cyrene_v2/internal/channel/adapter/httpclient/leak_test.go
T
AskaEth 4b4173dd42 fix(channel): M41 r1 复审三条 —— telegram 构造分支、QQ 改 MaskQuery、删两处死上限
P2-1 telegram doJSON 的**请求构造**失败分支:http.NewRequestWithContext 解析失败的
err 同样是 *url.Error(里面是整条 /bot<token>/… 地址),而 poll.go 的 long poll
failed 会原样落日志。改成与传输层分支同形:trace.HostOf(endpoint) + callCause。

P2-2 qq_obv11 connectClient 的成功 INFO 从 maskAccessTokenQuery 换成
trace.MaskQuery:后者认得 access%5Ftoken / token / api_key / 签名参数 / userinfo,
且保留 EscapedPath(路径可读)与不截断。maskAccessTokenQuery 仍被
sanitizeConnectReason 使用,未成死代码。

P2-3 删掉 maxEnvelopeMessageRunes / maxDescriptionRunes(各 200):trace.RedactText
内部已 truncate 到 maxSummaryRunes=80,这两层永不命中,注释口径与实际不符。改为
直接 return trace.RedactText(压平空白),注释写明 80 rune 由 RedactText 负责。

用例(全部变异打红,逐条验证):telegram 构造分支 token、QQ 八形态凭据矩阵
(含四种 maskAccessTokenQuery 漏掉的形态 + 路径可读反向断言)、两处 80 rune 上限。
2026-10-26 13:58:19 +08:00

173 lines
7.1 KiB
Go
Raw 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 httpclient
// 本文件钉住本客户端的两条出镜边界(M41 #10/#11 那一族):
//
// - **非 2xx 的响应体原文不进错误**:旧实现把体截到 512B 拼进 error,而调用方
// (KOOK 适配器)会把这条 error 直接落日志 —— 上游在体里回显什么,日志里就有
// 什么。现在只留状态码、来源主机,以及信封的 code/message(已清洗、已截断)。
// - **请求地址的凭据不进错误**:target 可能带 `?access_token=…` 或 `user:pass@`,
// 而 net/http 的 *url.Error 会把整条地址拼进错误串。
//
// 每条用例都是「把它改回去就变红」的形状:断言的是**错误文本**,也就是调用方实际
// 打进日志的那段字符串。
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// failingTransport 让 http.Client.Do 以传输层错误收场。选定它而不是拨一个真端口,
// 是因为只有传输层失败才会让 net/http 包出 **带完整地址** 的 *url.Error ——
// 那正是要钉住的泄漏形态。
type failingTransport struct{}
func (failingTransport) RoundTrip(*http.Request) (*http.Response, error) {
return nil, errors.New("dial tcp: connection refused")
}
// TestDoJSON_StatusErrorDoesNotEchoBodySentinel 4xx 体里放哨兵 ⇒ 错误里搜不到。
//
// 用非信封体(代理返回的 HTML 错误页就是这样):信封类的体按设计会保留
// code/message,非信封体则一个字节都不留。
func TestDoJSON_StatusErrorDoesNotEchoBodySentinel(t *testing.T) {
t.Parallel()
const sentinel = "SENTINEL-4XX-BODY"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("<html><body>bad gateway " + sentinel + "</body></html>"))
}))
defer srv.Close()
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
_, err := c.DoJSON(context.Background(), http.MethodPost, "/message/create", map[string]any{"a": 1})
if err == nil {
t.Fatal("502 却报成功")
}
if !errors.Is(err, ErrAPI) {
t.Fatalf("err = %v, want ErrAPI", err)
}
if strings.Contains(err.Error(), sentinel) {
t.Fatalf("响应体原文进了错误(会随调用方落日志):%v", err)
}
// 可诊断性不能一起丢:来源主机与状态码必须还在,且带得出「体有多大」。
host, _, ok := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
if !ok || !strings.Contains(err.Error(), host) {
t.Fatalf("错误里没有来源主机 %q:%v", host, err)
}
if !strings.Contains(err.Error(), "status=502") {
t.Fatalf("错误里没有状态码:%v", err)
}
if !strings.Contains(err.Error(), "body_bytes=") {
t.Fatalf("错误里没有响应体体积(相关性判断要用):%v", err)
}
}
// TestDoJSON_StatusErrorKeepsEnvelopeCode 信封体仍要给出 code/message:这是本客户端
// 唯一认识的诊断面,替换掉体原文之后不能连它一起丢。
func TestDoJSON_StatusErrorKeepsEnvelopeCode(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"code":40101,"message":"invalid token","data":null}`))
}))
defer srv.Close()
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
_, err := c.DoJSON(context.Background(), http.MethodGet, "/gateway/index", nil)
if err == nil {
t.Fatal("401 却报成功")
}
if !strings.Contains(err.Error(), "code 40101") || !strings.Contains(err.Error(), "invalid token") {
t.Fatalf("信封的 code/message 丢了:%v", err)
}
}
// TestDoJSON_NonZeroCodeRedactsMessageSecrets 信封 message 是上游自由文本面:里面的
// 凭据形状必须被清洗掉,长度必须被截断。改回原样透传就变红。
func TestDoJSON_NonZeroCodeRedactsMessageSecrets(t *testing.T) {
t.Parallel()
const secret = "big-secret-value-123456"
body := `{"code":40101,"message":"invalid access_token=` + secret + `","data":null}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()), WithToken(secret))
_, err := c.DoJSON(context.Background(), http.MethodGet, "/gateway/index", nil)
if err == nil {
t.Fatal("非零 code 却报成功")
}
if strings.Contains(err.Error(), secret) {
t.Fatalf("信封 message 里的凭据原样进了错误:%v", err)
}
}
// TestGetJSON_TransportErrorOmitsTargetCredentials 传输层失败时,错误里不得出现目标
// 地址(含 query 里的凭据)—— net/http 会把整条 URL 放在 *url.Error 里。
func TestGetJSON_TransportErrorOmitsTargetCredentials(t *testing.T) {
t.Parallel()
const secret = "QUERY-SECRET-VALUE"
c := New(
WithBaseURL("http://api.example.com"),
WithHTTPClient(&http.Client{Transport: failingTransport{}}),
WithLogger(discardLogger()),
)
_, err := c.GetJSON(context.Background(), "/gateway/index", url.Values{"access_token": {secret}})
if err == nil {
t.Fatal("传输层失败却报成功")
}
if strings.Contains(err.Error(), secret) {
t.Fatalf("目标地址的凭据进了错误:%v", err)
}
if !strings.Contains(err.Error(), "api.example.com") || !strings.Contains(err.Error(), "connection refused") {
t.Fatalf("剥掉地址时把来源或原因一起丢了:%v", err)
}
}
// TestGetJSON_UserinfoNeverEntersError 绝对地址带 user:pass@ 时同样不得出镜。
func TestGetJSON_UserinfoNeverEntersError(t *testing.T) {
t.Parallel()
c := New(
WithHTTPClient(&http.Client{Transport: failingTransport{}}),
WithLogger(discardLogger()),
)
_, err := c.GetJSON(context.Background(), "https://user:pass@api.example.com/x", nil)
if err == nil {
t.Fatal("传输层失败却报成功")
}
if strings.Contains(err.Error(), "user:pass") {
t.Fatalf("user:pass@ 进了错误:%v", err)
}
}
// TestDoJSON_EnvelopeMessageIsBoundedByRedactText 钉住长度口径:信封 message 只有一道
// 清洗(trace.RedactText,80 rune + 省略号)。本片曾按 200 rune 自己再截一刀,那层
// 永远命中不了 —— 这条用例让「到底哪一层在截」变成可断言的事实。
func TestDoJSON_EnvelopeMessageIsBoundedByRedactText(t *testing.T) {
t.Parallel()
long := strings.Repeat("a", 300)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"code":40101,"message":"` + long + `","data":null}`))
}))
defer srv.Close()
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
_, err := c.DoJSON(context.Background(), http.MethodGet, "/gateway/index", nil)
if err == nil {
t.Fatal("401 却报成功")
}
if !strings.Contains(err.Error(), strings.Repeat("a", 80)+"…") {
t.Fatalf("信封 message 没有按 80 rune 截断:%v", err)
}
if strings.Contains(err.Error(), strings.Repeat("a", 81)) {
t.Fatalf("信封 message 超过了 80 rune 上限:%v", err)
}
}