根因: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 层脱敏实现,发现原两条脱敏用例其实钉不住它(值恰好等于已知明文/被形状级顺带吃掉)⇒ 补一条钉住它独有窗口的用例(短值+紧邻空白),变异下必红、还原后绿。
252 lines
10 KiB
Go
252 lines
10 KiB
Go
package gateway
|
||
|
||
// 本文件锁定**连接态三态**在管理面上的如实转述(2026-10-07 的 UI 撒谎事故):
|
||
//
|
||
// - `POST /admin/channels/open` 之后 **不许**无条件报 connected=true;
|
||
// 适配器自报「重试中」时必须是 connected=false + pending + reason;
|
||
// - 适配器自报「就绪等接入」(server 模式)时是 connected=false + ready,
|
||
// 既不是「已连接」,也不是一句静态的「未连接」;
|
||
// - 没实现这条状态面的平台(绝大多数)响应逐字节不变 —— 可选键整键缺席。
|
||
//
|
||
// 断言打在**线上响应**上(不是内部函数),因为「面板读到什么」才是这件事的全部。
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"sync/atomic"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
)
|
||
|
||
// outcomeChannel 是一个会自报连接态三态的假适配器:Connect 不致命(照常返回
|
||
// nil),真实状态与原因完全由测试给定,因此「网关有没有照实转述」是可断言的事实。
|
||
type outcomeChannel struct {
|
||
name string
|
||
state string
|
||
reason string
|
||
connected atomic.Bool
|
||
connects atomic.Int32
|
||
}
|
||
|
||
func (c *outcomeChannel) Name() string { return c.name }
|
||
func (c *outcomeChannel) IsConnected() bool { return c.connected.Load() }
|
||
func (c *outcomeChannel) HealthCheck(context.Context) error { return nil }
|
||
func (c *outcomeChannel) ConnectOutcome() (string, string) { return c.state, c.reason }
|
||
|
||
func (c *outcomeChannel) Connect(context.Context) error {
|
||
c.connects.Add(1)
|
||
// 「真连上」才置位;pending / ready 都必须保持未连上 —— 这正是三态要区分的。
|
||
c.connected.Store(c.state == "connected")
|
||
return nil
|
||
}
|
||
|
||
func (c *outcomeChannel) Disconnect(context.Context) error {
|
||
c.connected.Store(false)
|
||
return nil
|
||
}
|
||
|
||
// openChannelData 发起一次 open 并解出 data 对象(原始 map,好断言「键在不在」)。
|
||
func openChannelData(t *testing.T, ch *outcomeChannel) map[string]any {
|
||
t.Helper()
|
||
caps := capreg.New()
|
||
if err := caps.RegisterChannel(ch); err != nil {
|
||
t.Fatalf("register: %v", err)
|
||
}
|
||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps})
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/open",
|
||
map[string]any{"name": ch.name}, tok)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("open status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
var out map[string]any
|
||
if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil {
|
||
t.Fatalf("decode open data: %v (body=%s)", err, body)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// TestChannelOpenReportsConnectedTruthfully 场景①:适配器自报真连上 ⇒ connected=true,
|
||
// 且三个可选键一律缺席(没有坏消息要额外说,老客户端形状不变)。
|
||
func TestChannelOpenReportsConnectedTruthfully(t *testing.T) {
|
||
t.Parallel()
|
||
out := openChannelData(t, &outcomeChannel{name: "qq-obv11", state: "connected"})
|
||
if out["connected"] != true {
|
||
t.Fatalf("connected = %v, want true", out["connected"])
|
||
}
|
||
for _, key := range []string{"pending", "ready", "reason"} {
|
||
if _, present := out[key]; present {
|
||
t.Fatalf("%s 不该出现在「真连上」的响应里: %v", key, out)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestChannelOpenReportsPendingWithReason 场景②(本次事故的正题):适配器自报
|
||
// 「已在后台重试、当前未连上」时,响应**绝不能**是 connected=true —— 那正是面板弹出
|
||
// 「已连接」而卡片一直显示「未连接」的根源。必须如实给出 connected=false + pending
|
||
// + reason,且 resolved/name 等既有键原样保留。
|
||
func TestChannelOpenReportsPendingWithReason(t *testing.T) {
|
||
t.Parallel()
|
||
const reason = "qq-obv11: dial onebot ws ws://localhost:10301/: dial tcp: connection refused"
|
||
out := openChannelData(t, &outcomeChannel{name: "qq-obv11", state: "pending", reason: reason})
|
||
|
||
if out["connected"] != false {
|
||
t.Fatalf("connected = %v, want false(重试中不许写成已连接)", out["connected"])
|
||
}
|
||
if out["pending"] != true {
|
||
t.Fatalf("pending = %v, want true(面板据此显示「正在连接」而不是「未连接」)", out["pending"])
|
||
}
|
||
if out["reason"] != reason {
|
||
t.Fatalf("reason = %v, want %q(必须把「为什么没连上」如实交出去)", out["reason"], reason)
|
||
}
|
||
if _, present := out["ready"]; present {
|
||
t.Fatalf("ready 不该与 pending 同时出现: %v", out)
|
||
}
|
||
if out["name"] != "qq-obv11" || out["resolved"] != "qq-obv11" {
|
||
t.Fatalf("既有键被破坏: %v", out)
|
||
}
|
||
}
|
||
|
||
// TestChannelOpenReportsReady 场景③:ws+server 已绑定监听、还没有对端接入。
|
||
// IsConnected() 同样是 false,但它**不是失败**,因此报 ready 而不是 pending,
|
||
// 也不带 reason(没有坏消息可说)。
|
||
func TestChannelOpenReportsReady(t *testing.T) {
|
||
t.Parallel()
|
||
out := openChannelData(t, &outcomeChannel{name: "qq-obv11", state: "ready"})
|
||
if out["connected"] != false {
|
||
t.Fatalf("connected = %v, want false(监听就绪 ≠ 已连上)", out["connected"])
|
||
}
|
||
if out["ready"] != true {
|
||
t.Fatalf("ready = %v, want true(就绪等接入不是失败)", out["ready"])
|
||
}
|
||
for _, key := range []string{"pending", "reason"} {
|
||
if _, present := out[key]; present {
|
||
t.Fatalf("%s 不该出现在「就绪等接入」的响应里: %v", key, out)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestChannelOpenWithoutOutcomeKeepsLegacyShape 守住「不动别人」:没实现连接态三态
|
||
// 的适配器(绝大多数平台)响应与今天逐字节一致 —— 三个可选键整键缺席,只有既有四键。
|
||
func TestChannelOpenWithoutOutcomeKeepsLegacyShape(t *testing.T) {
|
||
t.Parallel()
|
||
caps := capreg.New()
|
||
only := &spyChannel{name: "kook:3fc8"}
|
||
if err := caps.RegisterChannel(only); err != nil {
|
||
t.Fatalf("register: %v", err)
|
||
}
|
||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps})
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/open",
|
||
map[string]any{"name": "kook"}, tok)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
var out map[string]any
|
||
if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil {
|
||
t.Fatalf("decode: %v (body=%s)", err, body)
|
||
}
|
||
if len(out) != 3 || out["connected"] != true {
|
||
t.Fatalf("data = %v, want exactly {name,resolved,connected:true}", out)
|
||
}
|
||
for _, key := range []string{"pending", "ready", "reason"} {
|
||
if _, present := out[key]; present {
|
||
t.Fatalf("%s 不该出现(适配器没有这条状态面): %v", key, out)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestChannelCloseIsNotOptimistic 守住断开路径同样不乐观:报**真实的** IsConnected()。
|
||
// 一个「Disconnect 返回 nil 但其实没断开」的适配器必须被照实报成 connected=true,
|
||
// 而不是假定调用成功就等于断开了。
|
||
func TestChannelCloseIsNotOptimistic(t *testing.T) {
|
||
t.Parallel()
|
||
stuck := &outcomeChannel{name: "qq-obv11", state: "pending", reason: "still retrying"}
|
||
stuck.connected.Store(true) // 假装会话还活着,Disconnect 也没能拆掉它
|
||
stuckDisconnect := &stubbornChannel{outcomeChannel: stuck}
|
||
caps := capreg.New()
|
||
if err := caps.RegisterChannel(stuckDisconnect); err != nil {
|
||
t.Fatalf("register: %v", err)
|
||
}
|
||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps})
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/close",
|
||
map[string]any{"name": "qq-obv11"}, tok)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("close status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
var out map[string]any
|
||
if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil {
|
||
t.Fatalf("decode: %v (body=%s)", err, body)
|
||
}
|
||
if out["connected"] != true {
|
||
t.Fatalf("connected = %v, want true(实测仍未断开就必须照实报)", out["connected"])
|
||
}
|
||
if _, present := out["pending"]; present {
|
||
t.Fatalf("关闭路径不该投影三态: %v", out)
|
||
}
|
||
}
|
||
|
||
// stubbornChannel 是一个 Disconnect 不真正断开连接的假适配器:用来证明关闭路径读的
|
||
// 是真实 IsConnected(),而不是「调用成功 ⇒ 一定断开了」。
|
||
type stubbornChannel struct {
|
||
*outcomeChannel
|
||
}
|
||
|
||
func (c *stubbornChannel) Disconnect(context.Context) error { return nil }
|
||
|
||
// TestChannelListExposesConnectOutcome 守住列表侧:面板卡片的状态来自
|
||
// GET /admin/channels,因此三态必须在那里也如实出现(平台行与账号行都要),
|
||
// 否则点完连接刷新一次列表,「连接中」又会变回一句静态的「未连接」。
|
||
func TestChannelListExposesConnectOutcome(t *testing.T) {
|
||
t.Parallel()
|
||
const reason = "qq-obv11: dial onebot ws ws://localhost:10301/: dial tcp: connection refused"
|
||
ch := &outcomeChannel{name: "qq-obv11:9616b27a", state: "pending", reason: reason}
|
||
store := channelsStoreOf(t, "qq-obv11",
|
||
config.ChannelAccount{ID: "9616b27a", Config: map[string]any{"mode": "client"}})
|
||
caps := capreg.New()
|
||
if err := caps.RegisterChannel(ch); err != nil {
|
||
t.Fatalf("register: %v", err)
|
||
}
|
||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store})
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
|
||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, tok)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
var rows []struct {
|
||
Name string `json:"name"`
|
||
Connected bool `json:"connected"`
|
||
Pending bool `json:"pending"`
|
||
Reason string `json:"reason"`
|
||
Accounts []struct {
|
||
ID string `json:"id"`
|
||
Connected *bool `json:"connected"`
|
||
Pending bool `json:"pending"`
|
||
Reason string `json:"reason"`
|
||
} `json:"accounts"`
|
||
}
|
||
if err := json.Unmarshal(decodeBody(t, body)["data"], &rows); err != nil {
|
||
t.Fatalf("decode list: %v (body=%s)", err, body)
|
||
}
|
||
if len(rows) != 1 {
|
||
t.Fatalf("rows = %d, want 1 (body=%s)", len(rows), body)
|
||
}
|
||
row := rows[0]
|
||
if row.Connected || !row.Pending || row.Reason != reason {
|
||
t.Fatalf("平台行 = %+v, want connected=false pending=true reason=%q", row, reason)
|
||
}
|
||
if len(row.Accounts) != 1 {
|
||
t.Fatalf("accounts = %d, want 1", len(row.Accounts))
|
||
}
|
||
acct := row.Accounts[0]
|
||
if acct.ID != "9616b27a" || acct.Connected == nil || *acct.Connected || !acct.Pending || acct.Reason != reason {
|
||
t.Fatalf("账号行 = %+v, want id=9616b27a connected=false pending=true reason=%q", acct, reason)
|
||
}
|
||
}
|