根因: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 层脱敏实现,发现原两条脱敏用例其实钉不住它(值恰好等于已知明文/被形状级顺带吃掉)⇒ 补一条钉住它独有窗口的用例(短值+紧邻空白),变异下必红、还原后绿。
274 lines
11 KiB
Go
274 lines
11 KiB
Go
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)
|
||
}
|
||
}
|