Files
Cyrene_v2/internal/services/gateway/admin_channel_persona_test.go
T
AskaEth f9430dee81
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(admin): 渠道 personaId 改为三态语义(省略=保留 / ""=解绑 / id=绑定)——修「保存其它配置即静默清空绑定」
跨端语义相反:后端 5969bba 实现的是「省略/\"\"=解绑」,而管理面板只在用户改动下拉时才发该键 ⇒ owner 打开已绑账号、不动下拉、保存其它配置就会静默清空渠道角色绑定。
- 请求 DTO 改 *string 区分「省略」与「显式空串」:省略 → 保留现状;显式 "" → 解绑;非空 id → 绑定(未知角色 400);沿用读-改-写 + bridge.Reload 热更新 + 审计
- GET /admin/channels 继续回显 account.personaId(已确认)
- Docs/api-client 三态语义写清(省略=保留是本次的关键修正)
- 测试:省略保留 / 显式空串解绑 / id 绑定 / 未知 id 400 + 变异验证(把省略改回解绑 → 断言 FAIL)
2026-10-03 21:06:22 +08:00

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"
)
// This file covers the admin half of the channel-level role binding (design/22 §2
// 方案 b / §4.4): PUT /admin/channels/{platform}/config accepts personaId through the
// existing read-modify-write + bridge.Reload path, an unknown role is refused
// instead of binding a role that cannot answer, and the list endpoint reports the
// binding back.
//
// personaId is three-state on the wire, because the panel sends the key only when
// the owner actually touched the role select:
//
// key absent → keep the stored binding
// explicit "" → clear the binding
// non-empty id → bind that role (unknown id → 400)
//
// The absent-key case is the one that silently dropped bindings, so it is asserted
// with a literal JSON body: a map[string]any would still make the assertion pass if
// the DTO had picked up a value from somewhere else.
// putChannelConfigRaw posts a literal JSON body so a test can pin the exact wire
// shape (notably: no personaId key at all).
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 reads the binding channels.json holds for one account.
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 is the personaId GET /admin/channels reports for one account, i.e.
// what the panel reads back to render the role select.
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 binds a channel role and
// asserts it reached channels.json, that the list endpoint echoes it back for the
// panel to render, and that the hot-reload path ran.
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 is the regression for the
// silent data loss: the panel omits personaId unless the owner touched the role
// select, so saving another field must leave the stored binding alone.
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)
// The wire body carries the new token and no personaId key at all — exactly
// what an untouched role select produces.
_, 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)
}
// An explicit JSON null is the same as an absent key: the DTO cannot tell them
// apart, and neither means "clear".
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 asserts that clearing
// the binding still works, but only when the panel says so with an explicit "".
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 pins the read-modify-write to
// the account being saved: an omitted personaId on one account must inherit that
// account's binding, not a sibling's.
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 keeps the save fail-closed: a
// channel bound to a role the library does not know would silently answer as the
// default role, so it is refused and nothing is written or reloaded.
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)
}
}