package qq_obv11 // 本文件钉住 ws+client 连接成功那条 INFO 的出镜边界(M41 #15):remote_url 可以带 // 凭据 —— OneBot 惯例是 `?access_token=`,但真实配置里也见过 `?token=`、`?api_key=`、 // 签名参数,以及 `user:pass@`。原样 INFO 就是把凭据写进日志。 // // 打码走 trace.MaskQuery(同包 maskAccessTokenQuery 只认 access_token 一个键名,复审 // 形态矩阵里 `access%5Ftoken` / `token` / `api_key` / 签名 / userinfo 五种形态都不认)。 // 这条 INFO 的全部诊断价值是「连到了哪个端点」,因此路径必须原样可读 —— 每个用例都 // 反向断言这一点。 import ( "context" "net/http" "net/http/httptest" "strings" "testing" ) // wsServerForLogTest 起一个只说「握手 + 保持连接」的假 OneBot 服务端。它不解析路径与 // 查询串,因此下面每条形态都能连得上,测的纯粹是日志字段。 func wsServerForLogTest(t *testing.T) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { conn, err := upgradeWS(w, r) if err != nil { return } defer conn.Close() for { if _, _, err := conn.ReadMessage(); err != nil { return } } })) t.Cleanup(srv.Close) return srv } // connectAndLog 用给定的远端 URL 连一次,返回这段时间的全部日志文本。 func connectAndLog(t *testing.T, remote string) string { t.Helper() logs, logger := logCaptureSync() a := NewAdapter(Config{Mode: "client", RemoteURL: remote}, WithLogger(logger)) if err := a.Connect(context.Background()); err != nil { t.Fatalf("Connect(%q): %v", remote, err) } t.Cleanup(func() { _ = a.Disconnect(context.Background()) }) logged := logs.text() if !strings.Contains(logged, "connected to onebot ws") { t.Fatalf("没有连接成功日志,测试没覆盖到目标路径:\n%s", logged) } return logged } // TestConnectClientInfoLogMasksCredentials 是「凭据形态 × 打码」的矩阵:每种形态都要求 // 凭据搜不到、打码标记在场、路径仍可读。 func TestConnectClientInfoLogMasksCredentials(t *testing.T) { srv := wsServerForLogTest(t) hostport := strings.TrimPrefix(srv.URL, "http://") const secret = "SECRET-VALUE-1234" const path = "/onebot/v11/ws" cases := []struct { name string url string // leaked 是**不得出现**的子串(明文凭据的形态)。 leaked string // masked 是**必须出现**的打码结果片段,兼作「这条日志确实带了该字段」的反向断言。 masked string }{ { name: "access_token_query", url: "ws://" + hostport + path + "?access_token=" + secret, leaked: secret, masked: "?access_token=***", }, { name: "access_token_after_other_query", url: "ws://" + hostport + path + "?compress=0&access_token=" + secret, leaked: secret, masked: "&access_token=***", }, { name: "percent_encoded_key", url: "ws://" + hostport + path + "?access%5Ftoken=" + secret, leaked: secret, masked: "?access%5Ftoken=***", }, { name: "generic_token_key", url: "ws://" + hostport + path + "?token=" + secret, leaked: secret, masked: "?token=***", }, { name: "api_key_key", url: "ws://" + hostport + path + "?api_key=" + secret, leaked: secret, masked: "?api_key=***", }, { name: "signature_key", url: "ws://" + hostport + path + "?X-Signature=" + secret, leaked: secret, masked: "?X-Signature=***", }, { name: "fragment_token", url: "ws://" + hostport + path + "#access_token=" + secret, leaked: secret, masked: "#access_token=***", }, { name: "userinfo", url: "ws://apiuser:" + secret + "@" + hostport + path, leaked: secret, masked: "ws://[redacted]@" + hostport + path, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { logged := connectAndLog(t, tc.url) t.Logf("before=%q after=%s", tc.url, urlField(logged)) if strings.Contains(logged, tc.leaked) { t.Fatalf("凭据明文进了 INFO 日志:\n%s", logged) } if !strings.Contains(logged, tc.masked) { t.Fatalf("日志里没有打码结果 %q:\n%s", tc.masked, logged) } // 反向断言:路径必须还在 —— 否则「连到了哪个端点」这条诊断就没了。 if !strings.Contains(logged, path) { t.Fatalf("日志丢了路径 %q:\n%s", path, logged) } }) } } // urlField 摘出日志里 `url=…` 字段的值(渲染后可能带引号),只用于测试输出。 func urlField(logged string) string { for _, line := range strings.Split(logged, "\n") { if !strings.Contains(line, "connected to onebot ws") { continue } if i := strings.Index(line, " url="); i >= 0 { return line[i+1:] } } return "" }