package channelbridge // 本文件钉住「拨号失败的 WARN 不带凭据」这条红线(connectlog.go)。 // // 生产症状:qq-obv11 的 OneBot 端点按惯例把凭据写成 `?access_token=…`,拨号失败时 // 错误串里就是整条地址;原样 `"error", err` 落 WARN 等于把平台凭据写进日志。 // // 变异判据(每条都对应一个会红的实现): // - 把 connectFailureAttrs 换回 `"error", err` ⇒ SECRET 出现在日志里,用例红; // - 只做 trace.RedactText(不做 MaskQuery)⇒ 短 token 用例红(形状级匹配要求 // 赋值 ≥6 字符,3 字符的 token 逃得过去); // - 把 host 字段摘掉 ⇒ 「目标 host 仍在」断言红(打码不能把诊断一起打没)。 import ( "bytes" "context" "errors" "net/url" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/user" ) // dialFailureAdapter 是「Connect 以给定错误失败」的适配器:其余能力沿用 fakeAdapter。 type dialFailureAdapter struct { *fakeAdapter err error } func (a *dialFailureAdapter) Connect(context.Context) error { return a.err } // newLogBridge 构建一个把日志写进 out 的桥接器(其余依赖用测试桩)。 func newLogBridge(t *testing.T, out *bytes.Buffer) *Bridge { t.Helper() b, err := New(Options{ Log: logger.New(logger.Options{Format: string(logger.FormatJSON), Out: out}), Loop: &stubChatter{res: &loop.Result{Text: "hi"}}, Sessions: &stubSessions{sess: session.Session{SessionID: "s", Owner: true, MemWritable: true}}, Owner: &user.OwnerProfile{}, }) if err != nil { t.Fatalf("New: %v", err) } t.Cleanup(func() { b.Stop(context.Background()) }) return b } // qqDialAdapter 返回一个「配置完整、拨号必失败」的 qq-obv11 适配器。 func qqDialAdapter(err error) *dialFailureAdapter { return &dialFailureAdapter{ fakeAdapter: &fakeAdapter{ name: "qq-obv11:bot1", cfg: map[string]any{"mode": "client", "remoteURL": "wss://onebot.example.test/open"}, }, err: err, } } // startAndCapture 启动桥接器(连接同步完成)并返回它写下的日志。 func startAndCapture(t *testing.T, err error) string { t.Helper() var out bytes.Buffer b := newLogBridge(t, &out) b.Add(qqDialAdapter(err)) b.Start() return out.String() } // assertConnectWarn 断言 WARN 的三件事:仍然说「连不上」、目标 host 仍在、凭据不在。 func assertConnectWarn(t *testing.T, logged, secret string) { t.Helper() if !strings.Contains(logged, "channel connect failed, leaving disconnected") { t.Fatalf("日志里没有「连不上」这条 WARN:%s", logged) } if !strings.Contains(logged, "onebot.example.test") { t.Fatalf("日志丢了目标 host(打码不该连诊断一起打没):%s", logged) } if strings.Contains(logged, secret) { t.Fatalf("日志里出现了拨号凭据 %q:%s", secret, logged) } } // TestConnectFailureWarn_MasksURLErrorToken 覆盖 *url.Error 形状(net/http 与 // gorilla dial 的真实形态:Error() 会渲染整条地址)。 func TestConnectFailureWarn_MasksURLErrorToken(t *testing.T) { t.Parallel() const secret = "SECRET-TOKEN-VALUE" dialErr := &url.Error{ Op: "dial", URL: "wss://onebot.example.test/open?access_token=" + secret, Err: errors.New("websocket: bad handshake"), } assertConnectWarn(t, startAndCapture(t, dialErr), secret) } // TestConnectFailureWarn_MasksEmbeddedURLToken 覆盖「适配器自己把地址拼进文案」的 // 形状(qq-obv11 的 `dial onebot ws : `):这里没有 *url.Error // 外壳可剥,只能靠地址级打码 —— 因此 token 刻意取得**短于**形状级匹配的长度, // 只做 RedactText 的实现会在这里漏。 func TestConnectFailureWarn_MasksEmbeddedURLToken(t *testing.T) { t.Parallel() const secret = "abc" dialErr := errors.New("qq-obv11: dial onebot ws wss://onebot.example.test/open?access_token=" + secret + ": websocket: bad handshake") logged := startAndCapture(t, dialErr) assertConnectWarn(t, logged, secret) if !strings.Contains(logged, "access_token=***") { t.Fatalf("凭据参数的值应被打码成 ***(保留参数名便于排障):%s", logged) } }