按 design/22 §2/§3/§4.4/§7(3/4/5)。 - ①「消息级角色归属」进入模型上下文(批次 1 只做存储+DTO):历史消息的发送角色现在对模型可见,且**不把系统元数据塞进用户正文**(design/11 §10 红线) - ② IM owner 私聊镜像解析出的角色 = **主会话绑定的角色**(§7-3) - ③ 渠道级绑定:ChannelAccount 加 personaId(channels.json 读写/校验/脱敏沿用既有约定)+ 角色解析优先级 **会话 > 渠道 > 默认** + **群聊/陌生人强制默认角色**(§7-4);admin 渠道配置端点支持 personaId - Docs/api-client 版本 +1 - 验收:go build/vet/test 全绿 + linux/darwin 交叉编译
109 lines
4.4 KiB
Go
109 lines
4.4 KiB
Go
package gateway
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"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.
|
|
|
|
// TestAdminSaveChannelConfigPersonaID saves a channel role, asserts it reached
|
|
// channels.json and the list wire shape, then clears it.
|
|
func TestAdminSaveChannelConfigPersonaID(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)
|
|
}
|
|
accounts, warn := runtimeconfig.LoadChannels(path, nil)
|
|
if warn != nil {
|
|
t.Fatalf("load channels warn = %v", warn)
|
|
}
|
|
if len(accounts["telegram"]) != 1 || accounts["telegram"][0].PersonaID != "yuki" {
|
|
t.Fatalf("channels.json telegram = %+v, want personaId yuki", accounts["telegram"])
|
|
}
|
|
if len(store.reloaded) != 1 || store.reloaded[0] != "telegram" {
|
|
t.Fatalf("reloaded = %v, want [telegram] (the panel's hot-reload path)", store.reloaded)
|
|
}
|
|
|
|
// The list endpoint reports the binding so the panel can render it.
|
|
var listed []struct {
|
|
Name string `json:"name"`
|
|
Accounts []struct {
|
|
ID string `json:"id"`
|
|
PersonaID string `json:"personaId"`
|
|
} `json:"accounts"`
|
|
}
|
|
_, gBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, tok)
|
|
if err := json.Unmarshal(decodeBody(t, gBody)["data"], &listed); err != nil {
|
|
t.Fatalf("decode channels list: %v (body=%s)", err, gBody)
|
|
}
|
|
var accts []struct {
|
|
ID string `json:"id"`
|
|
PersonaID string `json:"personaId"`
|
|
}
|
|
for _, entry := range listed {
|
|
if entry.Name == "telegram" {
|
|
accts = entry.Accounts
|
|
}
|
|
}
|
|
if len(accts) != 1 || accts[0].PersonaID != "yuki" {
|
|
t.Fatalf("GET /admin/channels telegram accounts = %+v, want personaId yuki (body=%s)", accts, gBody)
|
|
}
|
|
|
|
// Saving the same account without a personaId unbinds it.
|
|
resp, body = doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/telegram/config",
|
|
map[string]any{"id": "conf0", "token": "bot-tok"}, tok)
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("unbind status = %d, want 201 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
accounts, _ = runtimeconfig.LoadChannels(path, nil)
|
|
if len(accounts["telegram"]) != 1 || accounts["telegram"][0].PersonaID != "" {
|
|
t.Fatalf("after unbind = %+v, want personaId cleared", accounts["telegram"])
|
|
}
|
|
}
|
|
|
|
// 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{}}
|
|
_, 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)
|
|
}
|
|
}
|