Files
Cyrene_v2/internal/user/save_test.go
T
AskaEth adef36538f
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(auth,gateway,user): X-API-Key 真接线 + key 一律超管 + owner 档案真落盘(裁决 15.4.2/15.4.3/10.4.5/08.4.2)
- 15.4.2:管理 API 与 Bearer 共用同一 Authorize 路径,X-API-Key 命中签发表即过门;未知/已吊销/空值 401 且不回落 Bearer(fail-closed)
- 15.4.3:key 表去掉角色维度,任一有效 key 鉴权后即 owner;签发接口取消 role 分级(不再读请求体)
- 10.4.5:新增 GET/PUT /api/v1/admin/owner-profile(owner-only,PUT 带审计 owner.profile.update),0600 原子落盘 + 活档案热替换;前端主人档案改接真实 API,读不到即禁用保存,删掉硬编码默认档案
- 08.4.2:persona 的 identity.systemPrompt 必填,缺失/空白 400 显式报错,不再派生回落
2026-10-04 12:07:51 +08:00

225 lines
8.0 KiB
Go

package user
import (
"errors"
"os"
"path/filepath"
"runtime"
"testing"
)
// TestSaveOwnerProfile_RoundTrip verifies a profile loaded from disk survives a
// Save → Load cycle without dropping curated fields (notes, identityFile, ...) or
// the account whitelist. It also exercises the atomic-write + parent-dir-create
// path (writing under a not-yet-existing subdirectory).
func TestSaveOwnerProfile_RoundTrip(t *testing.T) {
t.Parallel()
src := writeProfile(t, `id: owner
displayName: 叶酱
defaultPersona: cyrene
identityFile: configs/users/owner_identity.md
accounts:
- platform: kook
accountId: "329284282"
notes: "主人。陪伴对象。"
`)
orig, err := LoadOwnerProfile(src)
if err != nil {
t.Fatalf("load source: %v", err)
}
dst := filepath.Join(t.TempDir(), "nested", "deep", "owner.yaml")
if err := SaveOwnerProfile(dst, orig); err != nil {
t.Fatalf("SaveOwnerProfile: %v", err)
}
info, err := os.Stat(dst)
if err != nil {
t.Fatalf("stat saved profile: %v", err)
}
if runtime.GOOS != "windows" {
// Windows has no POSIX permission bits for regular files: Chmod only
// toggles the read-only flag and Stat reports 0666 regardless of the
// requested 0600, so this assertion is only meaningful on Unix-like
// platforms. SaveOwnerProfile still requests 0600 (see save.go).
if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("saved file mode = %o, want 0600", perm)
}
}
got, err := LoadOwnerProfile(dst)
if err != nil {
t.Fatalf("load saved: %v", err)
}
if got.ID != "owner" || got.DisplayName != "叶酱" || got.DefaultPersona != "cyrene" {
t.Fatalf("scalar fields = %+v, want owner/叶酱/cyrene", got)
}
if got.IdentityFile != "configs/users/owner_identity.md" {
t.Errorf("identityFile = %q, want preserved", got.IdentityFile)
}
if got.Notes != "主人。陪伴对象。" {
t.Errorf("notes = %q, want preserved", got.Notes)
}
if !got.IsOwner("kook", "329284282") {
t.Error("saved profile should still recognize the kook owner account")
}
}
// TestSaveOwnerProfile_NilProfile errors instead of panicking.
func TestSaveOwnerProfile_NilProfile(t *testing.T) {
t.Parallel()
if err := SaveOwnerProfile(filepath.Join(t.TempDir(), "o.yaml"), nil); err == nil {
t.Fatal("expected an error for a nil profile")
}
}
// TestRegisterOwnerAccount_AppendAndDedupe verifies that a new account is appended
// to the whitelist and recognized, a duplicate registration is a no-op, and an
// already-present account is never duplicated.
func TestRegisterOwnerAccount_AppendAndDedupe(t *testing.T) {
t.Parallel()
path := writeProfile(t, `id: owner
displayName: 叶酱
defaultPersona: cyrene
accounts:
- platform: kook
accountId: "329284282"
notes: "主人"
`)
if err := RegisterOwnerAccount(path, "weixin-ilink", "wx1"); err != nil {
t.Fatalf("register new account: %v", err)
}
p, err := LoadOwnerProfile(path)
if err != nil {
t.Fatalf("reload: %v", err)
}
if !p.IsOwner("weixin-ilink", "wx1") {
t.Error("newly registered (weixin-ilink, wx1) must be recognized as owner")
}
if !p.IsOwner("kook", "329284282") {
t.Error("existing (kook, 329284282) must still be recognized")
}
if got := len(p.Accounts); got != 2 {
t.Fatalf("accounts = %d, want 2 (kook + weixin-ilink)", got)
}
// Duplicate registration is a no-op.
if err := RegisterOwnerAccount(path, "weixin-ilink", "wx1"); err != nil {
t.Fatalf("re-register same account: %v", err)
}
p, _ = LoadOwnerProfile(path)
if got := len(p.Accounts); got != 2 {
t.Fatalf("accounts after duplicate = %d, want still 2", got)
}
// Re-registering an existing kook account is also a no-op.
if err := RegisterOwnerAccount(path, "kook", "329284282"); err != nil {
t.Fatalf("re-register existing kook: %v", err)
}
p, _ = LoadOwnerProfile(path)
if got := len(p.Accounts); got != 2 {
t.Fatalf("accounts after kook dedupe = %d, want still 2", got)
}
}
// TestRegisterOwnerAccount_ValidatesInput rejects empty platform / account id.
func TestRegisterOwnerAccount_ValidatesInput(t *testing.T) {
t.Parallel()
path := writeProfile(t, "id: owner\ndisplayName: 叶酱\naccounts: []\n")
if err := RegisterOwnerAccount(path, "", "wx1"); err == nil {
t.Error("empty platform must error")
}
if err := RegisterOwnerAccount(path, "weixin-ilink", ""); err == nil {
t.Error("empty account id must error")
}
}
// TestRegisterAccount_HotEffect verifies the in-memory mutation the channel bridge
// relies on: after RegisterAccount on a live profile, IsOwner sees it immediately
// and a duplicate is a no-op. This is the hot-reload path (no restart).
func TestRegisterAccount_HotEffect(t *testing.T) {
t.Parallel()
p := &OwnerProfile{ID: "owner", DisplayName: "叶酱",
Accounts: []OwnerAccount{{Platform: "kook", AccountID: "329284282"}}}
p.RegisterAccount("weixin-ilink", "wx1")
if !p.IsOwner("weixin-ilink", "wx1") {
t.Error("hot-registered (weixin-ilink, wx1) must be recognized immediately")
}
if len(p.Accounts) != 2 {
t.Fatalf("accounts = %d, want 2", len(p.Accounts))
}
p.RegisterAccount("weixin-ilink", "wx1") // duplicate
if len(p.Accounts) != 2 {
t.Fatalf("accounts after duplicate = %d, want still 2", len(p.Accounts))
}
}
// TestOwnerProfile_Replace_HotEffect 钉住后台面板保存后的热替换语义:Replace 把
// 新档案装进**同一个指针**(渠道 bridge 解析 actor 用的就是它),因此新白名单
// 立即生效、被移除的账号立即失效。换指针的实现会在这里失败。
func TestOwnerProfile_Replace_HotEffect(t *testing.T) {
t.Parallel()
live := &OwnerProfile{ID: "owner", DisplayName: "旧名",
Accounts: []OwnerAccount{{Platform: "kook", AccountID: "1"}}}
saved := &OwnerProfile{ID: "owner", DisplayName: "叶酱", DefaultPersona: "cyrene",
IdentityFile: "configs/users/owner_identity.md", Notes: "主人。",
Accounts: []OwnerAccount{{Platform: "telegram", AccountID: "10086"}}}
live.Replace(saved)
if live.DisplayName != "叶酱" || live.DefaultPersona != "cyrene" || live.Notes != "主人。" {
t.Fatalf("live profile = %+v, want the saved values", live)
}
if !live.IsOwner("telegram", "10086") {
t.Error("newly whitelisted account must be recognized immediately")
}
if live.IsOwner("kook", "1") {
t.Error("removed account must stop being the owner — Replace replaces the whole list")
}
// accounts 是副本:随后改动 saved 不会影响活档案。
saved.Accounts[0].AccountID = "999"
if live.IsOwner("telegram", "999") || !live.IsOwner("telegram", "10086") {
t.Error("live profile must not alias the saved profile's account slice")
}
}
// TestOwnerProfile_Replace_NilSafe 断言 nil 入参是安全空操作(不 panic)。
func TestOwnerProfile_Replace_NilSafe(t *testing.T) {
t.Parallel()
live := &OwnerProfile{ID: "owner", DisplayName: "叶酱"}
live.Replace(nil)
if live.DisplayName != "叶酱" {
t.Fatalf("DisplayName = %q, want unchanged", live.DisplayName)
}
var nilProfile *OwnerProfile
nilProfile.Replace(live) // 必须不 panic
}
// TestOwnerProfile_Validate 断言写路径能在落盘前用与载入期同一套规则拒掉坏档案:
// 缺 id/displayName、accounts 项只填一半都报错,而空白名单是合法的(全新安装
// 就是没人被配成主人,策略引擎默认拒绝)。
func TestOwnerProfile_Validate(t *testing.T) {
t.Parallel()
if err := (&OwnerProfile{ID: "owner", DisplayName: "叶酱"}).Validate(); err != nil {
t.Fatalf("valid profile error: %v", err)
}
if err := (&OwnerProfile{ID: "owner"}).Validate(); !errors.Is(err, ErrRequiredField) {
t.Fatalf("missing displayName err = %v, want ErrRequiredField", err)
}
if err := (&OwnerProfile{DisplayName: "叶酱"}).Validate(); !errors.Is(err, ErrRequiredField) {
t.Fatalf("missing id err = %v, want ErrRequiredField", err)
}
half := &OwnerProfile{ID: "owner", DisplayName: "叶酱", Accounts: []OwnerAccount{{Platform: "kook"}}}
if err := half.Validate(); !errors.Is(err, ErrRequiredField) {
t.Fatalf("half-filled account err = %v, want ErrRequiredField", err)
}
if err := (*OwnerProfile)(nil).Validate(); !errors.Is(err, ErrRequiredField) {
t.Fatalf("nil profile err = %v, want ErrRequiredField", err)
}
}