package qq_obv11 // 本文件测连接态三态的**适配器落点**:ConnectOutcome 如实回答「Connect 之后处于哪种 // 状态」,以及没连上时那条原因确实被脱敏(access_token 绝不出现)。 // // 为什么必须有它:2026-10-07 管理面板「点连接就弹已连接」的根因,就是适配器只对外给 // 一个 IsConnected() 布尔 —— ws+client 的首连失败刻意非致命(NapCat 等可能比 Cyrene // 后启动),于是「正在重试」「监听就绪」「起不来」三件事在布尔上长得一模一样。 // 纯判定部分(adapter.ConnectStateOf)见 internal/channel/adapter 的测试。 import ( "context" "errors" "fmt" "log/slog" "math" "net" "strings" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) // tokenLeakDialer 是一个**故意把 access_token 拼进错误串**的拨号替身:真实世界里任何 // 把拨号 URL 写进错误串的实现都会这样(ws_client.go 的 appendQueryToken 会把 token 拼 // 进 URL)。它使「脱敏真的接在了存储与日志前面」成为可断言的事实,而不是一句注释。 type tokenLeakDialer struct{ rawURL string } func (d tokenLeakDialer) dial(context.Context, string, string) (net.Conn, error) { return nil, fmt.Errorf("test: dial %s failed", d.rawURL) } // TestSanitizeConnectReasonStripsAccessToken 断言原因串里的 access_token 绝不出现, // 且留下可读的占位符(面板要能看出「这里原本是凭据」,而不是一段乱码)。 func TestSanitizeConnectReasonStripsAccessToken(t *testing.T) { t.Parallel() const token = "s3cr3t-token-value" raw := "websocket: dial ws://hook.example/onebot?access_token=" + token + "&self_id=1: connection refused" got := sanitizeConnectReason(raw, token) if strings.Contains(got, token) { t.Fatalf("脱敏后仍含 access_token 明文: %q", got) } if !strings.Contains(got, maskedCredential) { t.Errorf("脱敏后没有留下占位符: %q", got) } } // TestSanitizeConnectReasonStripsShortToken 断言**短、且紧邻其它参数**的 token 也剥得掉: // 形状级匹配要求赋值后至少 6 个非空白字符,`access_token=x&y` 这种逃得过它,只有 // 「已知明文 + 剥查询参数」这两层能兜住 —— 这正是它们存在的理由。 // // (参数名有时会被形状级兜底连同值一起吞掉:少一点诊断信息总好过泄露凭据, // 所以这里只断言**凭据绝不出现**,不绑定具体措辞。) func TestSanitizeConnectReasonStripsShortToken(t *testing.T) { t.Parallel() const token = "ab12" for _, raw := range []string{ "dial ws://h/p?access_token=ab12&self_id=1 failed", "websocket: dial ws://h/p?access_token=ab12: connection refused", } { got := sanitizeConnectReason(raw, token) if strings.Contains(got, token) { t.Errorf("短 token 泄露: %q → %q", raw, got) } if !strings.Contains(got, maskedCredential) { t.Errorf("脱敏后没有留下占位符: %q → %q", raw, got) } } } // TestSanitizeConnectReasonStripsUnknownShortQueryToken 钉住第 3 层(剥 `access_token=` // 查询参数)**独有**的那一小段窗口:值**短于 6 个字符、且紧邻空白/引号等终止符**时, // 形状级那条规则(要求赋值后至少 6 个非空白字符)匹配不上,而「已知明文替换」又因为 // 这把值**不是**我们配置的 token 而无从下手 —— 两层都落空,只剩这一层能兜住。 // // 边界是实测出来的,不是想象的:`access_token=ab12&self_id=1` 这种写法会被形状级 // **顺带**吃掉(它的值字符类一直吃到空格为止,于是 ≥6 字符成立),所以那类串钉不住 // 第 3 层;本用例特意让短值后面紧跟一个空白,正好落在第 1、2 层都够不着的那一格。 // 去掉第 3 层实现时本条必须变红 —— 它是那一层唯一的钉子。 func TestSanitizeConnectReasonStripsUnknownShortQueryToken(t *testing.T) { t.Parallel() const leaked = "ab12" raw := "websocket: dial ws://hook.example/onebot?access_token=" + leaked + " rejected during handshake" got := sanitizeConnectReason(raw, "") if strings.Contains(got, leaked) { t.Fatalf("未配置 token 时仍泄露了他处的 access_token 明文: %q", got) } if !strings.Contains(got, maskedCredential) { t.Errorf("脱敏后没有留下占位符: %q", got) } } // TestSanitizeConnectReasonEmptyStaysEmpty 断言「没有原因」与「被脱敏成空」不互相冒充。 func TestSanitizeConnectReasonEmptyStaysEmpty(t *testing.T) { t.Parallel() if got := sanitizeConnectReason("", "tok"); got != "" { t.Fatalf("空原因 = %q, want 空串", got) } } // TestConnectOutcomeClientPendingCarriesSanitizedReason 场景②(事故正题):ws+client // 首连失败 ⇒ Connect 照旧返回 nil(非致命),但 ConnectOutcome 必须如实报 pending // 并带上**脱敏后**的原因;日志里同样不许出现 token。 func TestConnectOutcomeClientPendingCarriesSanitizedReason(t *testing.T) { t.Parallel() const token = "super-secret-token" const rawURL = "ws://127.0.0.1:10301/onebot" store := &testLogStore{} a := NewAdapter(Config{ Mode: modeClient, RemoteURL: rawURL, AccessToken: token, DialContext: tokenLeakDialer{rawURL: appendQueryToken(rawURL, token)}.dial, Logger: slog.New(&testLogHandler{store: store}), }) a.policy = testReconnectPolicy() if err := a.Connect(context.Background()); err != nil { t.Fatalf("Connect() error = %v, want nil(首连失败必须非致命)", err) } t.Cleanup(func() { _ = a.Disconnect(context.Background()) }) state, reason := a.ConnectOutcome() if state != adapter.ConnectStatePending { t.Fatalf("ConnectOutcome state = %q, want %q(未连上且重试在跑)", state, adapter.ConnectStatePending) } if state == adapter.ConnectStateConnected { t.Fatal("首连失败被报成已连接 —— 这正是面板撒谎的根因") } if reason == "" { t.Fatal("pending 必须带上原因:面板要能把「为什么没连上」如实说出来") } if strings.Contains(reason, token) { t.Fatalf("原因串泄露 access_token: %q", reason) } assertNoTokenLeak(t, store, token) } // TestConnectOutcomeClientConnected 场景①:对面在时拨号成功 ⇒ connected,且没有原因 // 可挂(「已连接」旁边不该留一条过期的失败消息)。 func TestConnectOutcomeClientConnected(t *testing.T) { t.Parallel() srv, _ := newFakeOneBotServer(t) defer srv.Close() a := NewAdapter(Config{ Mode: modeClient, RemoteURL: "ws://" + strings.TrimPrefix(srv.URL, "http://"), DialContext: (&net.Dialer{}).DialContext, }) a.policy = testReconnectPolicy() if err := a.Connect(context.Background()); err != nil { t.Fatalf("Connect() error = %v", err) } t.Cleanup(func() { _ = a.Disconnect(context.Background()) }) state, reason := a.ConnectOutcome() if state != adapter.ConnectStateConnected || reason != "" { t.Fatalf("ConnectOutcome = (%q,%q), want (%q,\"\")", state, reason, adapter.ConnectStateConnected) } if !a.IsConnected() { t.Fatal("报 connected 却 IsConnected()=false:两个事实面必须一致") } } // TestConnectOutcomeServerReadyThenConnected 场景③:ws+server 绑定监听但没有对端接入时 // 是 ready(IsConnected() 同为 false,却**不是失败**);对端反连上来之后变 connected。 func TestConnectOutcomeServerReadyThenConnected(t *testing.T) { t.Parallel() a := NewAdapter(Config{Mode: modeServer, ListenAddr: "127.0.0.1:0"}) if err := a.Connect(context.Background()); err != nil { t.Fatalf("Connect() error = %v", err) } t.Cleanup(func() { _ = a.Disconnect(context.Background()) }) if a.IsConnected() { t.Fatal("还没有对端接入就报 IsConnected()=true") } if state, reason := a.ConnectOutcome(); state != adapter.ConnectStateReady || reason != "" { t.Fatalf("ConnectOutcome = (%q,%q), want (%q,\"\")", state, reason, adapter.ConnectStateReady) } client, err := dialWS(context.Background(), nil, "ws://"+a.Addr()+"/", "") if err != nil { t.Fatalf("dial server error: %v", err) } defer client.Close() if !waitUntil(3*time.Second, a.IsConnected) { t.Fatal("对端接入后仍未报已连接") } if state, _ := a.ConnectOutcome(); state != adapter.ConnectStateConnected { t.Fatalf("对端接入后 ConnectOutcome state = %q, want %q", state, adapter.ConnectStateConnected) } } // TestConnectOutcomeClearsReasonOnSuccess 断言连上时清空失败原因:一条已经过期的原因 // 不该继续挂在「已连接」旁边(面板会把过期的与当前的混为一谈)。 func TestConnectOutcomeClearsReasonOnSuccess(t *testing.T) { t.Parallel() srv, _ := newFakeOneBotServer(t) defer srv.Close() gate := make(chan struct{}) dial := &countingDialer{failTimes: 1, gate: gate, delegate: (&net.Dialer{}).DialContext} a := NewAdapter(Config{ Mode: modeClient, RemoteURL: "ws://" + strings.TrimPrefix(srv.URL, "http://"), DialContext: dial.dial, }) a.policy = testReconnectPolicy() if err := a.Connect(context.Background()); err != nil { t.Fatalf("Connect() error = %v", err) } t.Cleanup(func() { _ = a.Disconnect(context.Background()) }) if state, reason := a.ConnectOutcome(); state != adapter.ConnectStatePending || reason == "" { t.Fatalf("首连失败后 = (%q,%q), want pending 且带原因", state, reason) } close(gate) // 对面起来了 if !waitUntil(3*time.Second, a.IsConnected) { t.Fatal("第 2 次拨号成功却没有自动连上") } state, reason := a.ConnectOutcome() if state != adapter.ConnectStateConnected || reason != "" { t.Fatalf("连上后 = (%q,%q), want connected 且原因为空(过期的原因必须清掉)", state, reason) } } // TestConnectOutcomeAfterDisconnectHasNoOpinion 断言断开之后适配器「无意见」:会话已经 // 拆掉,「连的过程」不再是当前事实,网关据此退回只看 IsConnected()。 func TestConnectOutcomeAfterDisconnectHasNoOpinion(t *testing.T) { t.Parallel() a := NewAdapter(Config{ Mode: modeClient, RemoteURL: "ws://127.0.0.1:1/", DialContext: (&countingDialer{failTimes: math.MaxInt}).dial, }) a.policy = testReconnectPolicy() if err := a.Connect(context.Background()); err != nil { t.Fatalf("Connect() error = %v", err) } if err := a.Disconnect(context.Background()); err != nil { t.Fatalf("Disconnect() error = %v", err) } if state, reason := a.ConnectOutcome(); state != "" || reason != "" { t.Fatalf("Disconnect 后 = (%q,%q), want 无意见", state, reason) } } // TestConnectOutcomeConfigErrorStaysFatalNotPending 守住「配置错仍然是硬失败」:外层的 Connect 报错路径没被动过, // 因此配置错永远不会走到 ConnectOutcome 这里被说成 pending。 func TestConnectOutcomeConfigErrorStaysFatalNotPending(t *testing.T) { t.Parallel() a := NewAdapter(Config{Mode: modeClient}) err := a.Connect(context.Background()) if err == nil { t.Fatal("缺 remote_url 的 Connect() = nil, want error") } if !errors.Is(err, errClientConfig) { t.Fatalf("Connect() error = %v, want errClientConfig", err) } if state, _ := a.ConnectOutcome(); state != "" { t.Fatalf("未启动的适配器 ConnectOutcome = %q, want 无意见", state) } }