Files
Cyrene_v2/internal/services/gateway/admin_channel_connect_outcome_test.go
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

252 lines
10 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 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)
}
}