缺口①:「连接」是按平台连、且恒打第一条账号(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 行按普通行渲染)。
284 lines
11 KiB
Go
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
|
|
}
|