缺口①:「连接」是按平台连、且恒打第一条账号(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 行按普通行渲染)。
201 lines
8.2 KiB
Go
201 lines
8.2 KiB
Go
package gateway
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
|
)
|
|
|
|
// 本文件锁定「空 id 条目删得掉」以及它**必须显式**:
|
|
//
|
|
// - `?id=` 仍要求非空(老契约);
|
|
// - `?empty=1` 才表示「删无 id 的历史条目」,且只删这些条目;
|
|
// - 绝不能把「id 缺失/为空」默认读成「删掉所有无 id 条目」——
|
|
// 那是一次没有任何字面证据的批量删除。
|
|
|
|
// TestAdminDeleteChannelAccountEmptyExplicit 走 `?empty=1` 删除无 id 的历史条目:
|
|
// 文件里只剩带 id 的账号,且该平台被热重载。
|
|
func TestAdminDeleteChannelAccountEmptyExplicit(t *testing.T) {
|
|
t.Parallel()
|
|
path := t.TempDir() + "/channels.json"
|
|
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{
|
|
"qq-obv11": {
|
|
{Config: map[string]any{"mode": "server", "listen_addr": ""}}, // 无 id 的历史条目
|
|
{ID: "9616b27a", Config: map[string]any{"mode": "client", "remote_url": "ws://x/y"}},
|
|
{ID: "conf1", Config: map[string]any{"mode": "client", "remote_url": "ws://x/z"}},
|
|
},
|
|
}}
|
|
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
|
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
|
|
|
resp, body := doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/channels/qq-obv11/config?empty=1", nil, tok)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("delete status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
var res struct {
|
|
Persisted bool `json:"persisted"`
|
|
Platform string `json:"platform"`
|
|
Empty bool `json:"empty"`
|
|
Removed int `json:"removed"`
|
|
}
|
|
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
|
|
t.Fatalf("decode delete response: %v (body=%s)", err, body)
|
|
}
|
|
if !res.Persisted || res.Platform != "qq-obv11" || !res.Empty || res.Removed != 1 {
|
|
t.Fatalf("delete response = %+v, want persisted/qq-obv11/empty/removed=1", res)
|
|
}
|
|
if len(store.reloaded) != 1 || store.reloaded[0] != "qq-obv11" {
|
|
t.Fatalf("reloaded = %v, want [qq-obv11]", store.reloaded)
|
|
}
|
|
accounts, warn := runtimeconfig.LoadChannels(path, nil)
|
|
if warn != nil {
|
|
t.Fatalf("load channels warn = %v", warn)
|
|
}
|
|
kept := accounts["qq-obv11"]
|
|
if len(kept) != 2 {
|
|
t.Fatalf("kept accounts = %+v, want the two id'd accounts only", kept)
|
|
}
|
|
for _, acct := range kept {
|
|
if strings.TrimSpace(acct.ID) == "" {
|
|
t.Fatalf("an id-less account survived the explicit empty delete: %+v", kept)
|
|
}
|
|
}
|
|
if strings.Contains(string(body), "ws://x/y") || strings.Contains(string(body), "ws://x/z") {
|
|
t.Fatalf("delete response leaked config values: %s", body)
|
|
}
|
|
}
|
|
|
|
// TestAdminDeleteChannelAccountEmptyRequiresExplicitFlag 是核心回归:没有任何
|
|
// 字面证据时,空 id 请求一律 400,且盘上的无 id 条目**原封不动**(一次都不许写)。
|
|
func TestAdminDeleteChannelAccountEmptyRequiresExplicitFlag(t *testing.T) {
|
|
t.Parallel()
|
|
for _, query := range []string{"", "?id=", "?id=%20", "?empty=", "?empty=0", "?empty=false"} {
|
|
query := query
|
|
t.Run("query="+query, func(t *testing.T) {
|
|
t.Parallel()
|
|
path := t.TempDir() + "/channels.json"
|
|
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{
|
|
"qq-obv11": {{Config: map[string]any{"mode": "server"}}},
|
|
}}
|
|
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
|
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
|
|
|
resp, body := doReq(t, http.MethodDelete,
|
|
ts.URL+"/api/v1/admin/channels/qq-obv11/config"+query, nil, tok)
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
if len(store.reloaded) != 0 {
|
|
t.Fatalf("reloaded = %v, want none", store.reloaded)
|
|
}
|
|
if store.accounts["qq-obv11"] == nil || len(store.accounts["qq-obv11"]) != 1 {
|
|
t.Fatalf("accounts = %+v, want the id-less entry untouched", store.accounts)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAdminDeleteChannelAccountRejectsBothParams 断言 id 与 empty=1 同时给出时
|
|
// 一律 400:一次请求只针对一种条目,不做「两个都删」这种猜测。
|
|
func TestAdminDeleteChannelAccountRejectsBothParams(t *testing.T) {
|
|
t.Parallel()
|
|
path := t.TempDir() + "/channels.json"
|
|
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{
|
|
"qq-obv11": {
|
|
{Config: map[string]any{"mode": "server"}},
|
|
{ID: "9616b27a", Config: map[string]any{"mode": "client"}},
|
|
},
|
|
}}
|
|
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
|
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
|
|
|
resp, body := doReq(t, http.MethodDelete,
|
|
ts.URL+"/api/v1/admin/channels/qq-obv11/config?id=9616b27a&empty=1", nil, tok)
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
if len(store.reloaded) != 0 || len(store.accounts["qq-obv11"]) != 2 {
|
|
t.Fatalf("nothing may be deleted: reloaded=%v accounts=%+v", store.reloaded, store.accounts)
|
|
}
|
|
}
|
|
|
|
// TestAdminDeleteChannelAccountByIdStillTargeted 老契约照旧:?id=<非空> 只删那一条,
|
|
// 无 id 的历史条目**不受影响**。
|
|
func TestAdminDeleteChannelAccountByIdStillTargeted(t *testing.T) {
|
|
t.Parallel()
|
|
path := t.TempDir() + "/channels.json"
|
|
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{
|
|
"qq-obv11": {
|
|
{Config: map[string]any{"mode": "server"}},
|
|
{ID: "9616b27a", Config: map[string]any{"mode": "client"}},
|
|
},
|
|
}}
|
|
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
|
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
|
|
|
resp, body := doReq(t, http.MethodDelete,
|
|
ts.URL+"/api/v1/admin/channels/qq-obv11/config?id=9616b27a", nil, tok)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
kept := store.accounts["qq-obv11"]
|
|
if len(kept) != 1 || strings.TrimSpace(kept[0].ID) != "" {
|
|
t.Fatalf("kept = %+v, want only the id-less historical entry", kept)
|
|
}
|
|
}
|
|
|
|
// TestAdminDeleteChannelAccountsWithoutCandidatesIs404 断言显式请求了删除、但平台
|
|
// 上没有无 id 条目时诚实回 404(不写盘、不重载、不假装删过)。
|
|
func TestAdminDeleteChannelAccountsWithoutCandidatesIs404(t *testing.T) {
|
|
t.Parallel()
|
|
path := t.TempDir() + "/channels.json"
|
|
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{
|
|
"qq-obv11": {{ID: "9616b27a", Config: map[string]any{"mode": "client"}}},
|
|
}}
|
|
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
|
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
|
|
|
resp, body := doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/channels/qq-obv11/config?empty=1", nil, tok)
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
if len(store.reloaded) != 0 {
|
|
t.Fatalf("reloaded = %v, want none", store.reloaded)
|
|
}
|
|
if len(store.accounts["qq-obv11"]) != 1 {
|
|
t.Fatalf("accounts = %+v, want untouched", store.accounts)
|
|
}
|
|
}
|
|
|
|
// TestAdminDeleteChannelAccountEmptyDropsPlatformKey 断言只有无 id 条目时,
|
|
// 删除后整个平台键消失(与按 id 删除的收尾口径一致)。
|
|
func TestAdminDeleteChannelAccountEmptyDropsPlatformKey(t *testing.T) {
|
|
t.Parallel()
|
|
path := t.TempDir() + "/channels.json"
|
|
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{
|
|
"qq-obv11": {{Config: map[string]any{"mode": "server"}}},
|
|
"telegram": {{ID: "conf0", Config: map[string]any{"bot_token": "tk"}}},
|
|
}}
|
|
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
|
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
|
|
|
resp, body := doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/channels/qq-obv11/config?empty=1", nil, tok)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
if _, ok := store.accounts["qq-obv11"]; ok {
|
|
t.Fatalf("qq-obv11 key survived an empty delete: %+v", store.accounts)
|
|
}
|
|
if len(store.accounts["telegram"]) != 1 {
|
|
t.Fatalf("other platforms must be untouched: %+v", store.accounts)
|
|
}
|
|
if bytes := string(body); strings.Contains(bytes, "tk") {
|
|
t.Fatalf("delete response leaked a token: %s", bytes)
|
|
}
|
|
}
|