主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批: 已推库 16 个文件 + 本批剩余文件)。 校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0); 全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
248 lines
10 KiB
Go
248 lines
10 KiB
Go
package gateway
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"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"
|
||
)
|
||
|
||
// 本文件覆盖渠道级角色绑定的管理侧一半(design/22 §2
|
||
// 方案 b / §4.4):PUT /admin/channels/{platform}/config 通过
|
||
// 既有的 read-modify-write + bridge.Reload 路径接受 personaId,
|
||
// 未知角色会被拒绝,而不是绑定一个无法应答的角色;
|
||
// 列表端点会回报该绑定。
|
||
//
|
||
// personaId 在线上是三态的,因为面板仅在主人
|
||
// 真正触碰过角色选择时才发送该键:
|
||
//
|
||
// 键缺失 → 保留已存绑定
|
||
// 显式 "" → 清除绑定
|
||
// 非空 id → 绑定该角色(未知 id → 400)
|
||
//
|
||
// 「键缺失」这一情形正是静默丢弃绑定的那个,因此用字面量
|
||
// JSON 请求体断言:若 DTO 从别处取到了值,map[string]any
|
||
// 仍会让断言通过。
|
||
|
||
// putChannelConfigRaw 发送字面量 JSON 请求体,使测试能钉死确切的
|
||
// 线上结构(尤其是:完全没有 personaId 键)。
|
||
func putChannelConfigRaw(t *testing.T, ts *httptest.Server, platform, raw, token string) (*http.Response, []byte) {
|
||
t.Helper()
|
||
req, err := http.NewRequest(http.MethodPut,
|
||
ts.URL+"/api/v1/admin/channels/"+platform+"/config", bytes.NewReader([]byte(raw)))
|
||
if err != nil {
|
||
t.Fatalf("new request: %v", err)
|
||
}
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
t.Fatalf("do request: %v", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
b, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
t.Fatalf("read response: %v", err)
|
||
}
|
||
if resp.StatusCode != http.StatusCreated {
|
||
t.Fatalf("PUT %s status = %d, want 201 (body=%s)", platform, resp.StatusCode, b)
|
||
}
|
||
return resp, b
|
||
}
|
||
|
||
// storedPersonaID 读取 channels.json 为某个账号保存的绑定。
|
||
func storedPersonaID(t *testing.T, path, platform, accountID string) string {
|
||
t.Helper()
|
||
accounts, warn := runtimeconfig.LoadChannels(path, nil)
|
||
if warn != nil {
|
||
t.Fatalf("load channels warn = %v", warn)
|
||
}
|
||
for _, acct := range accounts[platform] {
|
||
if acct.ID == accountID {
|
||
return acct.PersonaID
|
||
}
|
||
}
|
||
t.Fatalf("account %s/%s not found in channels.json: %+v", platform, accountID, accounts[platform])
|
||
return ""
|
||
}
|
||
|
||
// listedPersonaID 是 GET /admin/channels 为某个账号报告的 personaId,
|
||
// 即面板读回用于渲染角色选择的值。
|
||
func listedPersonaID(t *testing.T, ts *httptest.Server, token, platform, accountID string) (string, []byte) {
|
||
t.Helper()
|
||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, token)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("get status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
var listed []struct {
|
||
Name string `json:"name"`
|
||
Accounts []struct {
|
||
ID string `json:"id"`
|
||
PersonaID string `json:"personaId"`
|
||
} `json:"accounts"`
|
||
}
|
||
if err := json.Unmarshal(decodeBody(t, body)["data"], &listed); err != nil {
|
||
t.Fatalf("decode channels list: %v (body=%s)", err, body)
|
||
}
|
||
for _, entry := range listed {
|
||
if entry.Name != platform {
|
||
continue
|
||
}
|
||
for _, acct := range entry.Accounts {
|
||
if acct.ID == accountID {
|
||
return acct.PersonaID, body
|
||
}
|
||
}
|
||
}
|
||
t.Fatalf("account %s/%s not in GET /admin/channels (body=%s)", platform, accountID, body)
|
||
return "", body
|
||
}
|
||
|
||
// TestAdminSaveChannelConfigPersonaIDBindsAndEchoes 绑定一个渠道角色,
|
||
// 断言它到达了 channels.json、列表端点回显它供面板渲染,
|
||
// 并且热重载路径被执行。
|
||
func TestAdminSaveChannelConfigPersonaIDBindsAndEchoes(t *testing.T) {
|
||
t.Parallel()
|
||
path := filepath.Join(t.TempDir(), "channels.json")
|
||
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{}}
|
||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store, Personas: personaTestRegistry(t)})
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
|
||
resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/telegram/config",
|
||
map[string]any{"id": "conf0", "token": "bot-tok", "personaId": "yuki"}, tok)
|
||
if resp.StatusCode != http.StatusCreated {
|
||
t.Fatalf("save status = %d, want 201 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
if got := storedPersonaID(t, path, "telegram", "conf0"); got != "yuki" {
|
||
t.Fatalf("channels.json telegram.conf0 personaId = %q, want yuki", got)
|
||
}
|
||
if len(store.reloaded) != 1 || store.reloaded[0] != "telegram" {
|
||
t.Fatalf("reloaded = %v, want [telegram] (the panel's hot-reload path)", store.reloaded)
|
||
}
|
||
|
||
echoed, listBody := listedPersonaID(t, ts, tok, "telegram", "conf0")
|
||
if echoed != "yuki" {
|
||
t.Fatalf("GET /admin/channels telegram.conf0 personaId = %q, want yuki (body=%s)", echoed, listBody)
|
||
}
|
||
t.Logf("GET /api/v1/admin/channels echoes the binding: %s", listBody)
|
||
}
|
||
|
||
// TestAdminSaveChannelConfigPersonaIDKeptWhenOmitted 是静默数据
|
||
// 丢失的回归:除非主人触碰过角色选择,面板会省略 personaId,
|
||
// 因此保存另一个字段必须不碰已存绑定。
|
||
func TestAdminSaveChannelConfigPersonaIDKeptWhenOmitted(t *testing.T) {
|
||
t.Parallel()
|
||
path := filepath.Join(t.TempDir(), "channels.json")
|
||
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{
|
||
"telegram": {{ID: "conf0", Config: map[string]any{"bot_token": "old-tok"}, PersonaID: "yuki"}},
|
||
}}
|
||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store, Personas: personaTestRegistry(t)})
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
|
||
// 线上请求体携带新 token,且完全没有 personaId 键 ——
|
||
// 正是一个未被触碰的角色选择所产生的形态。
|
||
_, body := putChannelConfigRaw(t, ts,
|
||
"telegram", `{"id":"conf0","token":"new-tok","groupMsgPerMin":5}`, tok)
|
||
|
||
if got := storedPersonaID(t, path, "telegram", "conf0"); got != "yuki" {
|
||
t.Fatalf("omitted personaId cleared the binding: channels.json personaId = %q, want yuki (resp=%s)", got, body)
|
||
}
|
||
accounts, _ := runtimeconfig.LoadChannels(path, nil)
|
||
if accounts["telegram"][0].Config["bot_token"] != "new-tok" || accounts["telegram"][0].GroupMsgPerMin != 5 {
|
||
t.Fatalf("the other fields were not saved: %+v", accounts["telegram"][0])
|
||
}
|
||
if echoed, listBody := listedPersonaID(t, ts, tok, "telegram", "conf0"); echoed != "yuki" {
|
||
t.Fatalf("GET /admin/channels personaId = %q, want yuki (body=%s)", echoed, listBody)
|
||
}
|
||
|
||
// 显式的 JSON null 与键缺失相同:DTO 无法区分二者,
|
||
// 二者都不表示「清除」。
|
||
putChannelConfigRaw(t, ts, "telegram", `{"id":"conf0","token":"newer-tok","personaId":null}`, tok)
|
||
if got := storedPersonaID(t, path, "telegram", "conf0"); got != "yuki" {
|
||
t.Fatalf("null personaId cleared the binding: personaId = %q, want yuki", got)
|
||
}
|
||
}
|
||
|
||
// TestAdminSaveChannelConfigPersonaIDClearedOnExplicitEmpty 断言清除
|
||
// 绑定仍然可行,但仅在面板以显式 "" 表示时才如此。
|
||
func TestAdminSaveChannelConfigPersonaIDClearedOnExplicitEmpty(t *testing.T) {
|
||
t.Parallel()
|
||
path := filepath.Join(t.TempDir(), "channels.json")
|
||
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{
|
||
"telegram": {{ID: "conf0", Config: map[string]any{"bot_token": "old-tok"}, PersonaID: "yuki"}},
|
||
}}
|
||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store, Personas: personaTestRegistry(t)})
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
|
||
putChannelConfigRaw(t, ts, "telegram", `{"id":"conf0","token":"new-tok","personaId":""}`, tok)
|
||
|
||
if got := storedPersonaID(t, path, "telegram", "conf0"); got != "" {
|
||
t.Fatalf("explicit \"\" personaId = %q, want the binding cleared", got)
|
||
}
|
||
if echoed, listBody := listedPersonaID(t, ts, tok, "telegram", "conf0"); echoed != "" {
|
||
t.Fatalf("GET /admin/channels personaId = %q, want empty (body=%s)", echoed, listBody)
|
||
}
|
||
}
|
||
|
||
// TestAdminSaveChannelConfigPersonaIDKeptPerAccount 把 read-modify-write
|
||
// 钉在正在保存的账号上:某账号省略 personaId 时必须继承该
|
||
// 账号自己的绑定,而不是兄弟账号的。
|
||
func TestAdminSaveChannelConfigPersonaIDKeptPerAccount(t *testing.T) {
|
||
t.Parallel()
|
||
path := filepath.Join(t.TempDir(), "channels.json")
|
||
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{
|
||
"telegram": {
|
||
{ID: "conf0", Config: map[string]any{"bot_token": "a"}, PersonaID: "yuki"},
|
||
{ID: "conf1", Config: map[string]any{"bot_token": "b"}},
|
||
},
|
||
}}
|
||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store, Personas: personaTestRegistry(t)})
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
|
||
putChannelConfigRaw(t, ts, "telegram", `{"id":"conf1","token":"b2"}`, tok)
|
||
|
||
if got := storedPersonaID(t, path, "telegram", "conf0"); got != "yuki" {
|
||
t.Fatalf("sibling account conf0 personaId = %q, want yuki untouched", got)
|
||
}
|
||
if got := storedPersonaID(t, path, "telegram", "conf1"); got != "" {
|
||
t.Fatalf("conf1 personaId = %q, want empty (it never had a binding)", got)
|
||
}
|
||
}
|
||
|
||
// TestAdminSaveChannelConfigRejectsUnknownPersonaID 保持保存 fail-closed:
|
||
// 绑定到一个库不认识的角色时,渠道会静默地以默认角色应答,
|
||
// 因此该绑定被拒绝,不写入也不重载。
|
||
func TestAdminSaveChannelConfigRejectsUnknownPersonaID(t *testing.T) {
|
||
t.Parallel()
|
||
path := filepath.Join(t.TempDir(), "channels.json")
|
||
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{
|
||
"telegram": {{ID: "conf0", Config: map[string]any{"bot_token": "old-tok"}, PersonaID: "yuki"}},
|
||
}}
|
||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store, Personas: personaTestRegistry(t)})
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
|
||
resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/telegram/config",
|
||
map[string]any{"id": "conf0", "token": "bot-tok", "personaId": "nobody"}, tok)
|
||
if resp.StatusCode != http.StatusBadRequest {
|
||
t.Fatalf("unknown personaId status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
if !strings.Contains(string(body), "personaId") {
|
||
t.Fatalf("rejection body = %s, want it to name personaId", body)
|
||
}
|
||
if len(store.reloaded) != 0 {
|
||
t.Fatalf("reloaded = %v, want none for a refused save", store.reloaded)
|
||
}
|
||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||
t.Fatalf("a refused save must not write channels.json (stat err = %v)", err)
|
||
}
|
||
}
|