Files
AskaEth b4d55f412e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(channel): 连接动作不再乐观 —— 适配器自报连接态三态 + 面板如实呈现
根因:adminChannelOpen 在 ch.Connect 返回 nil 后硬编码 connected=true。而 Connect 返回 nil 只说明「这次调用没有致命失败」—— ws+client 首连失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),ws+server 则只需监听就绪,两种情况 IsConnected() 都是 false。前端又只按「请求成功」写死「已连接」,于是管理面把「后端受理了」说成「渠道连上了」。

改法:三态口径落在适配器自己身上(connected/pending/ready,internal/channel/adapter 的纯函数 ConnectStateOf 判定 + ConnectSignals 结构化入参,避免两个 bool 接反),qq_obv11 实现 ConnectOutcome 并记住脱敏后的失败原因;网关经类型断言探测(与 accountStatusAware/rescanAware 同套路,核心 sdk.Channel 契约刻意不扩展),只做映射不猜 transport/mode。

线上投影:open/close 响应与 GET /admin/channels 的平台行/账号行各新增 pending/ready/reason 三个可选键(零值即整键缺席,同 NeedsRescan/LastError 口径);既有四键逐字不变,未实现该状态面的平台输出逐字节不变。close 报真实 IsConnected(),断开也不乐观。

凭据边界:reason 三层脱敏(形状级 → 已知明文替换 → 剥 access_token= 查询参数),日志同样只用脱敏串。

验收:主会话亲自复跑 go build/vet 全绿、全树 go test 无 FAIL、目标三包 test 全绿、前端 tsc --noEmit 干净、esbuild 逻辑测试 34+46 checks 全过;主会话亲自变异摘掉第 3 层脱敏实现,发现原两条脱敏用例其实钉不住它(值恰好等于已知明文/被形状级顺带吃掉)⇒ 补一条钉住它独有窗口的用例(短值+紧邻空白),变异下必红、还原后绿。
2026-10-13 19:38:56 +08:00

274 lines
11 KiB
Go
Raw Permalink 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 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)
}
}