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) } }