Files
Cyrene_v2/internal/services/gateway/admin_channel_action_test.go
T
AskaEth 42279b7124 feat(channels): 按账号连接/断开 + 可删空 id 条目(修主人真机踩的两个缺口)
缺口①:「连接」是按平台连、且恒打第一条账号(channelActionRequest 只有 name)⇒ 主人有两条
qq-obv11 时点到的是 mode=server 的历史条目,报 server mode requires listenAddr,而正确的
client 条目轮不到。缺口②:空 id 条目既删不掉(DELETE 要求 id 非空)也改不了(UpsertChannel
空 id = 新建),越改越多。

后端:
- POST /admin/channels/open|close 请求体新增可选 accountId(缺省逐字兼容,老客户端零影响);
  按该账号驱动;未知 id ⇒ 404 且零连接(不静默回落第一条);已配置未注册 ⇒ 409
- DELETE /admin/channels/{platform}/config 新增显式 empty=1(id 与 empty=1 互斥 ⇒ 400;
  无可删 ⇒ 404);语义严格 —— 只删空 id 条目,绝不把「id 为空」默认解释成删空 id
- 响应新增:open/close 的 resolved(实际驱动的适配器名)与 accountId;delete 的 removed/empty;
  GET /admin/channels 的 accounts[].connected(可选 bool,缺省 = 未知)
- 审计动作名不变(channel.open/close/delete),日志行新增 accountId 字段
前端:每条账号一行「连接/断开」(带 accountId);按钮旁明确「会连哪一条」;空 id 行显著标注
+ 删除入口;channelActionBody 携带 accountId;新增 action 测试 33 checks + 空 id 行渲染 31 checks。
测试:后端 14 个用例(真实 HTTP 路由 + JWT + 真注册表 + spy 适配器,断言「哪一条被驱动、
另一条一次都没被碰」)。
变异 6 条(accountId 被忽略 / id 为空当删空 id / 找不到静默回落 / 前端丢 accountId /
删空 id 退化成 ?id= / 空 id 行按普通行渲染)。
2026-10-05 10:37:56 +08:00

284 lines
11 KiB
Go

package gateway
import (
"context"
"encoding/json"
"net/http"
"strings"
"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"
)
// 本文件锁定「连接到底连了谁」这一根本困惑:
//
// - 带 accountId 的 open/close **只**作用于该账号;
// - 找不到该 id 时明确报错(404 account_not_found / 409 account_not_registered),
// **绝不静默回落到第一条**(静默回落正是主人点「连接」打到历史条目的根因);
// - 不带 accountId 时维持老契约,且在多账号平台上按**配置顺序**确定目标
// (注册表是 map,旧代码的「第一条」其实每次可能不同,面板无法如实标注)。
// spyChannel 记录 Connect/Disconnect 是否真被调用,使「面板点的那一条被驱动了、
// 另一条没被碰」成为可断言的证据。
type spyChannel struct {
name string
connects atomic.Int32
disconns atomic.Int32
connected atomic.Bool
connectErr error
}
func (s *spyChannel) Name() string { return s.name }
func (s *spyChannel) IsConnected() bool { return s.connected.Load() }
func (s *spyChannel) HealthCheck(context.Context) error { return nil }
func (s *spyChannel) Connect(context.Context) error {
s.connects.Add(1)
if s.connectErr != nil {
return s.connectErr
}
s.connected.Store(true)
return nil
}
func (s *spyChannel) Disconnect(context.Context) error {
s.disconns.Add(1)
s.connected.Store(false)
return nil
}
// twoAccountFixture 建一个「两条 qq-obv11」的现场:一条无 id 的历史条目
// (裸适配器名)与一条主人新建的 client 账号,并返回 spy 句柄。
func twoAccountFixture(t *testing.T, store *stubChannelConfigStore) (*spyChannel, *spyChannel, string, *auth.JWTService) {
t.Helper()
legacy := &spyChannel{name: "qq-obv11"}
fresh := &spyChannel{name: "qq-obv11:9616b27a"}
caps := capreg.New()
if err := caps.RegisterChannel(legacy); err != nil {
t.Fatalf("register legacy: %v", err)
}
if err := caps.RegisterChannel(fresh); err != nil {
t.Fatalf("register fresh: %v", err)
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store})
return legacy, fresh, ts.URL, jwt
}
// worktreeTestStore 返回一个临时 channels.json 存储,含该平台的给定账号。
func channelsStoreOf(t *testing.T, platform string, accts ...config.ChannelAccount) *stubChannelConfigStore {
t.Helper()
return &stubChannelConfigStore{
path: t.TempDir() + "/channels.json",
accounts: map[string][]config.ChannelAccount{platform: accts},
}
}
// TestAdminChannelOpenTargetsRequestedAccount 断言 accountId 给出时连的是它,
// 而不是该平台注册表里的第一条。
func TestAdminChannelOpenTargetsRequestedAccount(t *testing.T) {
t.Parallel()
legacy, fresh, base, jwt := twoAccountFixture(t, nil)
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, base+"/api/v1/admin/channels/open",
map[string]any{"name": "qq-obv11", "accountId": "9616b27a"}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("open status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if got := fresh.connects.Load(); got != 1 {
t.Fatalf("fresh account Connect calls = %d, want 1", got)
}
if got := legacy.connects.Load(); got != 0 {
t.Fatalf("legacy account Connect calls = %d, want 0(不得打到别条账号)", got)
}
var res struct {
Name string `json:"name"`
Resolved string `json:"resolved"`
AccountID string `json:"accountId"`
Connected bool `json:"connected"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
t.Fatalf("decode open response: %v (body=%s)", err, body)
}
// resolved 必须如实报出**被驱动**的适配器:面板靠它显示「连的是哪一条」。
if res.Resolved != "qq-obv11:9616b27a" || res.AccountID != "9616b27a" || !res.Connected {
t.Fatalf("open response = %+v, want resolved=qq-obv11:9616b27a accountId=9616b27a connected=true", res)
}
}
// TestAdminChannelCloseTargetsRequestedAccount 同上,针对 close。
func TestAdminChannelCloseTargetsRequestedAccount(t *testing.T) {
t.Parallel()
legacy, fresh, base, jwt := twoAccountFixture(t, nil)
fresh.connected.Store(true)
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, base+"/api/v1/admin/channels/close",
map[string]any{"name": "qq-obv11", "accountId": "9616b27a"}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("close status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if fresh.disconns.Load() != 1 || legacy.disconns.Load() != 0 {
t.Fatalf("disconnect calls fresh=%d legacy=%d, want 1/0", fresh.disconns.Load(), legacy.disconns.Load())
}
if fresh.IsConnected() {
t.Fatal("fresh account still connected after close")
}
}
// TestAdminChannelOpenUnknownAccountIDIs404 断言「找不到该 id」是明确错误,
// 且**绝不**把请求打到该平台的第一条账号(静默回落是本 bug 的根因)。
func TestAdminChannelOpenUnknownAccountIDIs404(t *testing.T) {
t.Parallel()
store := channelsStoreOf(t, "qq-obv11",
config.ChannelAccount{ID: "9616b27a", Config: map[string]any{"mode": "client"}})
legacy, fresh, base, jwt := twoAccountFixture(t, store)
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, base+"/api/v1/admin/channels/open",
map[string]any{"name": "qq-obv11", "accountId": "does-not-exist"}, tok)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404 (body=%s)", resp.StatusCode, body)
}
errObj := decodeAdminError(t, body)
if errObj.Code != "account_not_found" {
t.Fatalf("error code = %q, want account_not_found", errObj.Code)
}
// 错误文案必须带上可用账号,方便面板/调用方自行纠正(账号 id 非敏感)。
if !strings.Contains(errObj.Message, "9616b27a") {
t.Fatalf("error message = %q, want it to list the known account id", errObj.Message)
}
if legacy.connects.Load() != 0 || fresh.connects.Load() != 0 {
t.Fatalf("no account may be connected on a miss: legacy=%d fresh=%d",
legacy.connects.Load(), fresh.connects.Load())
}
}
// TestAdminChannelOpenConfiguredButUnregisteredIs409 区分两种 miss:账号确实在
// channels.json 里、但当前没有它的活适配器(多半缺必要凭据),给 409 而不是 404,
// 面板据此提示「去修该账号配置」而不是「账号不存在」。
func TestAdminChannelOpenConfiguredButUnregisteredIs409(t *testing.T) {
t.Parallel()
store := channelsStoreOf(t, "qq-obv11",
config.ChannelAccount{ID: "9616b27a", Config: map[string]any{"mode": "client"}})
caps := capreg.New()
legacy := &spyChannel{name: "qq-obv11"}
if err := caps.RegisterChannel(legacy); 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.MethodPost, ts.URL+"/api/v1/admin/channels/open",
map[string]any{"name": "qq-obv11", "accountId": "9616b27a"}, tok)
if resp.StatusCode != http.StatusConflict {
t.Fatalf("status = %d, want 409 (body=%s)", resp.StatusCode, body)
}
if code := decodeAdminError(t, body).Code; code != "account_not_registered" {
t.Fatalf("error code = %q, want account_not_registered", code)
}
if legacy.connects.Load() != 0 {
t.Fatal("unregistered account must not fall back to another account")
}
}
// TestAdminChannelOpenWithoutAccountIDStaysLegacy 断言缺省 accountId 时仍是老契约:
// 单账号平台照旧连上那一条,且响应不凭空造出 accountId。
func TestAdminChannelOpenWithoutAccountIDStaysLegacy(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)
}
if only.connects.Load() != 1 {
t.Fatalf("connect calls = %d, want 1", only.connects.Load())
}
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 _, present := out["accountId"]; present {
t.Fatalf("platform-level open must not invent an accountId: %v", out)
}
if out["name"] != "kook" || out["resolved"] != "kook:3fc8" {
t.Fatalf("open data = %v, want name=kook resolved=kook:3fc8", out)
}
}
// TestAdminChannelOpenPlatformLevelIsDeterministic 断言多账号平台上的**平台级**
// 连接目标按配置顺序确定,而不是随注册表 map 顺序抖动 —— 否则面板无法如实标注
// 「这次会连哪一条」,主人也就永远不知道平台级按钮会打到谁。
func TestAdminChannelOpenPlatformLevelIsDeterministic(t *testing.T) {
t.Parallel()
caps := capreg.New()
first := &spyChannel{name: "qq-obv11:aaaa1111"}
second := &spyChannel{name: "qq-obv11:bbbb2222"}
for _, ch := range []*spyChannel{second, first} { // 注册顺序与配置顺序故意相反
if err := caps.RegisterChannel(ch); err != nil {
t.Fatalf("register: %v", err)
}
}
store := channelsStoreOf(t, "qq-obv11",
config.ChannelAccount{ID: "aaaa1111", Config: map[string]any{"mode": "client"}},
config.ChannelAccount{ID: "bbbb2222", Config: map[string]any{"mode": "client"}},
)
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
for i := 0; i < 12; i++ {
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/open",
map[string]any{"name": "qq-obv11"}, 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", err)
}
if out["resolved"] != "qq-obv11:aaaa1111" {
t.Fatalf("round %d resolved = %v, want the first configured account (aaaa1111)", i, out["resolved"])
}
}
if first.connects.Load() != 12 || second.connects.Load() != 0 {
t.Fatalf("connects first=%d second=%d, want 12/0", first.connects.Load(), second.connects.Load())
}
}
// TestAdminChannelOpenDecodeFailureStill400 保证老客户的坏请求体照旧 400。
func TestAdminChannelOpenDecodeFailureStill400(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doRaw(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/open", "{", tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
}
// decodeAdminError 解出 {error:{code,message}} 信封。
func decodeAdminError(t *testing.T, body []byte) struct {
Code string `json:"code"`
Message string `json:"message"`
} {
t.Helper()
var out struct {
Code string `json:"code"`
Message string `json:"message"`
}
raw := decodeBody(t, body)["error"]
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("decode error envelope: %v (body=%s)", err, body)
}
return out
}