Files
Cyrene_v2/internal/services/gateway/admin_channel_persona_test.go
T
AskaEth 074ffc4ebf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(gateway): gateway 剩余文件注释改中文(逐行 1:1,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批:
已推库 16 个文件 + 本批剩余文件)。
校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0);
全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
2026-10-03 22:28:25 +08:00

248 lines
10 KiB
Go
Raw 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
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)
}
}