diff --git a/cmd/cyrene/wire.go b/cmd/cyrene/wire.go index 60f934f..0c5cbc5 100644 --- a/cmd/cyrene/wire.go +++ b/cmd/cyrene/wire.go @@ -114,6 +114,40 @@ type capacityEmitter interface { BroadcastCapacityAwaken(sessionID, fromState, toState, tool string) } +// ownerRegistrar implements gateway.OwnerRegistrar: it persists a channel account +// id into the owner whitelist (configs/users/owner.yaml) AND hot-updates the live +// owner profile the channel bridge holds, so a weixin scan-login confirmation is +// recognized as the owner immediately — no restart, no manual edit. path and the +// live profile are captured at wiring time: the boot-loaded *user.OwnerProfile is +// the same pointer the bridge resolves actors against, so RegisterAccount on it is +// visible to ResolveActor without touching the bridge. +type ownerRegistrar struct { + path string + owner *user.OwnerProfile + log *slog.Logger +} + +func (r *ownerRegistrar) RegisterChannelOwner(_ context.Context, platform, accountID string) error { + if strings.TrimSpace(accountID) == "" { + return nil + } + if err := user.RegisterOwnerAccount(r.path, platform, accountID); err != nil { + return fmt.Errorf("register owner %s:%s: %w", platform, accountID, err) + } + // Hot-effect: extend the live profile the bridge already points at. If the + // profile failed to load at boot (r.owner nil) the persist above still lands + // on disk and is recognized after a restart; log so the operator knows the + // hot path was unavailable. + if r.owner != nil { + r.owner.RegisterAccount(platform, accountID) + } else { + r.log.Warn("owner registrar: no live profile, whitelist persisted to disk only (restart to take effect)", + "platform", platform, "accountId", accountID) + } + r.log.Info("owner whitelist registered", "platform", platform, "accountId", accountID) + return nil +} + // newApp assembles the full Cyrene runtime from cfg and log. It opens the // database, migrates the schema, wires the persona registry, model adapter, tool // dispatcher, memory stack, agent loop, orchestration, execution, IoT, channel @@ -625,29 +659,30 @@ func (a *app) wireFrontend() error { // and the gateway share the same persistent store. History was a no-op dependency // before the IM path, so nothing downstream depends on its creation order change. gw, err := gateway.New(a.cfg, gateway.Deps{ - Log: logger.WithSource(a.log, "gateway"), - Auth: jwt, - Bus: a.bus, - Registry: a.caps, - Loop: a.loop, - Session: a.sessions, - Personas: a.personas, - Memories: a.memStore, - History: a.history, - IoT: a.iot, - Scheduler: a.scheduler, - Tasks: a.tasks, - Automations: a.automation, - Users: apiKeys, - Backthink: a.backthink, - RAG: ragStore, - LLM: a.llm, - Channels: a.channels, - AuditStore: auditStore, - Streaming: true, - Frontend: resolveFrontendFS(a.log), - Webhooks: a.channelBridge.WebhookHandlers(), - DatabasePath: a.dbPath, + Log: logger.WithSource(a.log, "gateway"), + Auth: jwt, + Bus: a.bus, + Registry: a.caps, + Loop: a.loop, + Session: a.sessions, + Personas: a.personas, + Memories: a.memStore, + History: a.history, + IoT: a.iot, + Scheduler: a.scheduler, + Tasks: a.tasks, + Automations: a.automation, + Users: apiKeys, + Backthink: a.backthink, + RAG: ragStore, + LLM: a.llm, + Channels: a.channels, + OwnerRegistrar: &ownerRegistrar{path: defaultOwnerPath, owner: a.owner, log: a.log}, + AuditStore: auditStore, + Streaming: true, + Frontend: resolveFrontendFS(a.log), + Webhooks: a.channelBridge.WebhookHandlers(), + DatabasePath: a.dbPath, }) if err != nil { return fmt.Errorf("gateway: %w", err) @@ -668,7 +703,6 @@ func (a *app) wireFrontend() error { } a.capacitySink = gw - // Wire the backthink proactive sinks now that the gateway hub (active-client // route) and the channel bridge (IM fallback) both exist. They were absent when // backthink.New ran (the hub needs the loop and the bridge needs the loop and the diff --git a/internal/services/gateway/admin_channels_weixin.go b/internal/services/gateway/admin_channels_weixin.go index a57b98c..0dfc280 100644 --- a/internal/services/gateway/admin_channels_weixin.go +++ b/internal/services/gateway/admin_channels_weixin.go @@ -42,9 +42,9 @@ type weixinLoginAdmin interface { // identifier (qrcode); the frontend renders a scannable QR from qrUrl, since the // liteapp URL is an HTML page that cannot be fetched as an image. type weixinLoginQRResponse struct { - QrUrl string `json:"qrUrl,omitempty"` - QrCode string `json:"qrCode,omitempty"` - Status string `json:"status"` + QrUrl string `json:"qrUrl,omitempty"` + QrCode string `json:"qrCode,omitempty"` + Status string `json:"status"` } // weixinLoginStatusResponse is the wire shape of GET /admin/channels/{platform}/login-status. @@ -106,7 +106,7 @@ func (s *Server) adminWeixinLoginStatus(w http.ResponseWriter, r *http.Request) ownerID, _ := cfg["owner_id"].(string) resp.BotID = botID resp.BotToken = maskToken(token) - if err := s.persistWeixinConfirmed(platform, accountID, token, botID, ownerID); err != nil { + if err := s.persistWeixinConfirmed(r.Context(), platform, accountID, token, botID, ownerID); err != nil { s.log.Error("weixin login persist", "platform", platform, "err", err) } else { s.auditClaims(r, "channel.weixin.login.persist", "ok") @@ -122,8 +122,13 @@ func (s *Server) adminWeixinLoginStatus(w http.ResponseWriter, r *http.Request) // persistWeixinConfirmed writes the login-obtained credentials into the account's // channels.json config (merging onto the user's curated base_url/cdn fields and // preserving its allow/deny/limit lists) and hot-reloads the platform so the -// bridge connects the now-configured adapter. -func (s *Server) persistWeixinConfirmed(platform, accountID, token, botID, ownerID string) error { +// bridge connects the now-configured adapter. When ownerID is non-empty it also +// registers the scanner's bound ilink_user_id into the owner whitelist +// (owner.yaml) so the bridge recognizes it as the owner without a manual edit. +// The whitelist registration is best-effort: a failure is logged but does not +// fail the already-persisted channels.json, since the identity red line never +// blocks a successful bind. +func (s *Server) persistWeixinConfirmed(ctx context.Context, platform, accountID, token, botID, ownerID string) error { if s.channels == nil { return fmt.Errorf("weixin login: channel config store not injected") } @@ -183,7 +188,19 @@ func (s *Server) persistWeixinConfirmed(platform, accountID, token, botID, owner if err := runtimeconfig.SaveChannels(s.channels.Path(), updated); err != nil { return err } - return s.channels.Reload(platform) + if err := s.channels.Reload(platform); err != nil { + return err + } + // Stamp the scanner (the bound owner) into the owner whitelist so the bridge + // recognizes it structurally. Best-effort: never fail an already-successful + // bind because a whitelist write hiccupped — the operator is alerted by a log + // line instead. + if ownerID != "" && s.ownerRegistrar != nil { + if err := s.ownerRegistrar.RegisterChannelOwner(ctx, platform, ownerID); err != nil { + s.log.Error("weixin login owner register", "platform", platform, "err", err) + } + } + return nil } // weixinAccountFor resolves the *weixin_ilink.Adapter registered for the diff --git a/internal/services/gateway/admin_channels_weixin_test.go b/internal/services/gateway/admin_channels_weixin_test.go index c857386..2f3363b 100644 --- a/internal/services/gateway/admin_channels_weixin_test.go +++ b/internal/services/gateway/admin_channels_weixin_test.go @@ -6,6 +6,7 @@ package gateway // unwrap the gateway uses to reach the concrete *weixin_ilink.Adapter. import ( + "context" "encoding/json" "io" "log/slog" @@ -81,9 +82,9 @@ func newWeixinGateway(t *testing.T, name string, channels ChannelConfigStore) (s wsrv := weixinLoginServer(t) wa, err := weixin_ilink.New( weixin_ilink.Config{ - BaseURL: wsrv.URL, - LoginInterval: time.Millisecond, - LoginTimeout: 5 * time.Second, + BaseURL: wsrv.URL, + LoginInterval: time.Millisecond, + LoginTimeout: 5 * time.Second, }, wsrv.Client(), slog.New(slog.NewTextHandler(io.Discard, nil)), @@ -176,7 +177,9 @@ func TestAdminWeixinLoginStatus_Waiting(t *testing.T) { t.Fatalf("login-status status = %d, body=%s", resp.StatusCode, body) } var out struct { - Data struct{ Status string `json:"status"` } `json:"data"` + Data struct { + Status string `json:"status"` + } `json:"data"` } if err := json.Unmarshal(body, &out); err != nil { t.Fatalf("decode login-status: %v", err) @@ -290,7 +293,9 @@ func TestAdminWeixinLoginStatus_AlreadyConfirmed(t *testing.T) { t.Fatalf("login-qr status = %d, body=%s", resp.StatusCode, body) } var qr struct { - Data struct{ Status string `json:"status"` } `json:"data"` + Data struct { + Status string `json:"status"` + } `json:"data"` } if err := json.Unmarshal(body, &qr); err != nil { t.Fatalf("decode login-qr: %v", err) @@ -327,3 +332,66 @@ func TestAdminWeixinLogin_NonWeixinNotImplemented(t *testing.T) { t.Fatalf("status = %d, want 501 (body=%s)", resp.StatusCode, body) } } + +// ownerRegCall records one RegisterChannelOwner invocation. +type ownerRegCall struct { + platform, accountID string +} + +// captureOwnerRegistrar records whitelist registrations for the weixin confirm path. +type captureOwnerRegistrar struct { + calls []ownerRegCall +} + +func (c *captureOwnerRegistrar) RegisterChannelOwner(_ context.Context, platform, accountID string) error { + c.calls = append(c.calls, ownerRegCall{platform, accountID}) + return nil +} + +// TestPersistWeixinConfirmed_RegistersOwner verifies that once a scan-login +// confirmation persists the channels.json credentials, the bound owner id is +// handed to the OwnerRegistrar as (weixin-ilink, ilink_user_id), and that an empty +// owner id skips the registration entirely. +func TestPersistWeixinConfirmed_RegistersOwner(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "channels.json") + chs := &fakeWeixinChannels{ + path: path, + data: map[string][]config.ChannelAccount{ + "weixin-ilink": {{ID: "conf0", Config: map[string]any{"base_url": "https://base"}}}, + }, + } + reg := &captureOwnerRegistrar{} + s := &Server{ + channels: chs, + ownerRegistrar: reg, + log: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + + if err := s.persistWeixinConfirmed(context.Background(), "weixin-ilink", "conf0", "tok-x", "bot-x", "o-1"); err != nil { + t.Fatalf("persistWeixinConfirmed: %v", err) + } + if len(reg.calls) != 1 || reg.calls[0].platform != "weixin-ilink" || reg.calls[0].accountID != "o-1" { + t.Fatalf("registrar calls = %+v, want [weixin-ilink/o-1]", reg.calls) + } + + // An empty owner id (a confirmed bind that yields no bound user) must not + // register anything. + if err := s.persistWeixinConfirmed(context.Background(), "weixin-ilink", "conf0", "tok-y", "bot-y", ""); err != nil { + t.Fatalf("persistWeixinConfirmed(no owner): %v", err) + } + if len(reg.calls) != 1 { + t.Fatalf("registrar calls after empty ownerID = %+v, want still 1", reg.calls) + } +} + +// TestNew_WiresOwnerRegistrar verifies the composition point: Deps.OwnerRegistrar +// is threaded onto the Server so the weixin confirm flow has a live registrar. +func TestNew_WiresOwnerRegistrar(t *testing.T) { + t.Parallel() + reg := &captureOwnerRegistrar{} + srv, _, _ := buildEnv(t, cfgDefault(), Deps{OwnerRegistrar: reg}) + if srv.ownerRegistrar != reg { + t.Fatalf("ownerRegistrar = %#v, want the injected registrar", srv.ownerRegistrar) + } +} diff --git a/internal/services/gateway/server.go b/internal/services/gateway/server.go index 26fb920..bd97570 100644 --- a/internal/services/gateway/server.go +++ b/internal/services/gateway/server.go @@ -176,6 +176,18 @@ type ChannelConfigStore interface { Reload(platform string) error } +// OwnerRegistrar registers a channel account id into the owner whitelist +// (configs/users/owner.yaml). It is the structural identity red line: a scan-login +// confirmation stamps the scanner's bound ilink_user_id into the whitelist so the +// channel bridge recognizes it as the owner via actor metadata, never a prompt. +// The composition root implements it (cmd/cyrene's ownerRegistrar) to persist the +// whitelist and hot-update the live owner profile. Registration is best-effort: +// a failure is logged by the caller but does not fail the already-applied +// channels.json persist. +type OwnerRegistrar interface { + RegisterChannelOwner(ctx context.Context, platform, accountID string) error +} + // RagStore backs the admin knowledge-base RAG module (design/10 §3.12 module 8). // *runtime/knowledge.Store is the production implementation. Every method is // read-only for this milestone: IndexDocument/DeleteDocument return a clear @@ -245,6 +257,11 @@ type Deps struct { // Optional: when nil the admin /channels/config endpoints return a clear // not-configured error, matching how the LLM stack behaves when unset. Channels ChannelConfigStore + // OwnerRegistrar stamps a scan-login's bound owner id into the owner + // whitelist (configs/users/owner.yaml) and hot-updates the live profile. + // Optional: when nil the weixin confirm flow still persists channels.json but + // skips the whitelist registration (best-effort by design). + OwnerRegistrar OwnerRegistrar // AuditStore persists the admin audit trail (observability → audit). When nil // the gateway degrades to an in-memory store so the endpoint still returns // data; the composition root injects NewSQLiteAuditStore over a.db for real @@ -301,9 +318,12 @@ type Server struct { // that do not wire it keep working. llm *model.RuntimeLLM channels ChannelConfigStore - webhooks map[string]http.Handler - log *slog.Logger - hub *hub + // ownerRegistrar stamps a scan-login bound owner id into the owner whitelist + // (best-effort). See Deps.OwnerRegistrar. + ownerRegistrar OwnerRegistrar + webhooks map[string]http.Handler + log *slog.Logger + hub *hub } // New validates cfg and deps and returns a ready Server. Auth is required; the @@ -317,30 +337,31 @@ func New(cfg *config.Config, deps Deps) (*Server, error) { return nil, fmt.Errorf("gateway: auth dependency required") } s := &Server{ - cfg: cfg, - auth: deps.Auth, - bus: deps.Bus, - caps: deps.Registry, - runner: deps.Loop, - sessions: deps.Session, - personas: deps.Personas, - memories: deps.Memories, - history: deps.History, - iot: deps.IoT, - auditor: deps.Scheduler, - taskOutputs: deps.Tasks, - automations: deps.Automations, - users: deps.Users, - backthink: deps.Backthink, - rag: deps.RAG, - obs: newObservability(deps), - streaming: deps.Streaming, - frontend: deps.Frontend, - llm: deps.LLM, - channels: deps.Channels, - webhooks: deps.Webhooks, - dbPath: deps.DatabasePath, - log: sourcedLog(deps.Log), + cfg: cfg, + auth: deps.Auth, + bus: deps.Bus, + caps: deps.Registry, + runner: deps.Loop, + sessions: deps.Session, + personas: deps.Personas, + memories: deps.Memories, + history: deps.History, + iot: deps.IoT, + auditor: deps.Scheduler, + taskOutputs: deps.Tasks, + automations: deps.Automations, + users: deps.Users, + backthink: deps.Backthink, + rag: deps.RAG, + obs: newObservability(deps), + streaming: deps.Streaming, + frontend: deps.Frontend, + llm: deps.LLM, + channels: deps.Channels, + ownerRegistrar: deps.OwnerRegistrar, + webhooks: deps.Webhooks, + dbPath: deps.DatabasePath, + log: sourcedLog(deps.Log), } s.hub = newHub(s) s.hub.orchestrator = newOrchestrator(s) diff --git a/internal/user/owner.go b/internal/user/owner.go index 1b93de4..45d7b53 100644 --- a/internal/user/owner.go +++ b/internal/user/owner.go @@ -4,6 +4,8 @@ // this whitelist — and is never driven by prompt text or an LLM judgment. package user +import "sync" + // OwnerAccount is a single channel-fingerprint that belongs to the owner. The // policy engine matches channel-layer actor metadata (platform + account id) // against this list. @@ -16,7 +18,14 @@ type OwnerAccount struct { // persona bound to the owner, and the per-channel account whitelist. It is the // source data for owner recognition; it is not injected into the persona's // prompt as an authority. +// +// mu guards Accounts so a live profile can be hot-extended (RegisterAccount) +// while the channel bridge's ResolveActor reads it (IsOwner) concurrently. The +// mutex is unexported and carries no YAML tag, so it is invisible to encode and +// is not part of the persisted profile. type OwnerProfile struct { + mu sync.RWMutex `yaml:"-"` + ID string `yaml:"id"` DisplayName string `yaml:"displayName"` DefaultPersona string `yaml:"defaultPersona"` @@ -28,8 +37,15 @@ type OwnerProfile struct { } // IsOwner reports whether the given (platform, accountID) pair belongs to the -// owner according to the whitelist. +// owner according to the whitelist. It is safe for concurrent use with +// RegisterAccount (the bridge reads this on every inbound message while a +// scan-login registration may extend the whitelist). func (p *OwnerProfile) IsOwner(platform, accountID string) bool { + if p == nil { + return false + } + p.mu.RLock() + defer p.mu.RUnlock() for _, a := range p.Accounts { if a.Platform == platform && a.AccountID == accountID { return true @@ -38,9 +54,33 @@ func (p *OwnerProfile) IsOwner(platform, accountID string) bool { return false } +// RegisterAccount appends (platform, accountID) to the whitelist if it is not +// already present, leaving the profile otherwise untouched. It is the in-memory +// counterpart of RegisterOwnerAccount: the channel bridge holds the very same +// *OwnerProfile, so a scan-login confirmation can extend the whitelist without a +// restart. Duplicate (platform, accountID) pairs are a no-op, matching IsOwner. +func (p *OwnerProfile) RegisterAccount(platform, accountID string) { + if p == nil { + return + } + p.mu.Lock() + defer p.mu.Unlock() + for _, a := range p.Accounts { + if a.Platform == platform && a.AccountID == accountID { + return + } + } + p.Accounts = append(p.Accounts, OwnerAccount{Platform: platform, AccountID: accountID}) +} + // AccountIdentifiers returns the distinct channel account ids owned by the -// owner, for diagnostics / listing. +// owner, for diagnostics / listing. Safe for concurrent use with RegisterAccount. func (p *OwnerProfile) AccountIdentifiers() []string { + if p == nil { + return nil + } + p.mu.RLock() + defer p.mu.RUnlock() seen := make(map[string]struct{}, len(p.Accounts)) out := make([]string, 0, len(p.Accounts)) for _, a := range p.Accounts { diff --git a/internal/user/save.go b/internal/user/save.go new file mode 100644 index 0000000..6d1313e --- /dev/null +++ b/internal/user/save.go @@ -0,0 +1,91 @@ +package user + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// SaveOwnerProfile writes p to path as YAML, atomically (temp file + rename) with +// mode 0600 and lenient parent-directory creation, matching the runtime config +// convention (SaveChannels). The whole struct is marshaled, so curated fields +// (notes, identityFile, displayName, ...) survive a round-trip. YAML comments in +// the existing file are not part of the struct and are dropped by the encode, +// consistent with the project's other config writers. +func SaveOwnerProfile(path string, p *OwnerProfile) error { + if p == nil { + return fmt.Errorf("user: save owner profile: empty profile") + } + if strings.TrimSpace(path) == "" { + return fmt.Errorf("user: save owner profile: empty path") + } + data, err := yaml.Marshal(p) + if err != nil { + return fmt.Errorf("user: encode %s: %w", path, err) + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("user: create %s: %w", dir, err) + } + tmp, err := os.CreateTemp(dir, ".owner-*.yaml") + if err != nil { + return fmt.Errorf("user: temp file in %s: %w", dir, err) + } + tmpName := tmp.Name() + closed := false + defer func() { + _ = tmp.Close() + if !closed { + _ = os.Remove(tmpName) + } + }() + // CreateTemp opens with 0600; chmod explicitly so the on-disk file never + // carries a broader mode even if the umask changes. + if err := tmp.Chmod(0o600); err != nil { + return fmt.Errorf("user: chmod %s: %w", tmpName, err) + } + if _, err := tmp.Write(data); err != nil { + return fmt.Errorf("user: write %s: %w", tmpName, err) + } + if err := tmp.Sync(); err != nil { + return fmt.Errorf("user: sync %s: %w", tmpName, err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("user: close %s: %w", tmpName, err) + } + closed = true + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("user: rename to %s: %w", path, err) + } + // Best-effort dir fsync so the rename is durable across a crash. + if d, derr := os.Open(dir); derr == nil { + _ = d.Sync() + _ = d.Close() + } + return nil +} + +// RegisterOwnerAccount loads the owner whitelist at path, appends (platform, +// accountID) when it is not already present (idempotent), and writes the updated +// profile back. It returns an error when the profile cannot be loaded or saved. +// The on-disk replace is atomic, so a concurrent reader never sees a partial +// file. Hot-reload of a running owner profile is the caller's concern (see +// cmd/cyrene's ownerRegistrar), which applies RegisterAccount to the live profile +// the channel bridge already holds. +func RegisterOwnerAccount(path, platform, accountID string) error { + if strings.TrimSpace(platform) == "" { + return fmt.Errorf("user: register owner account: empty platform") + } + if strings.TrimSpace(accountID) == "" { + return fmt.Errorf("user: register owner account: empty account id") + } + p, err := LoadOwnerProfile(path) + if err != nil { + return fmt.Errorf("user: load %s: %w", path, err) + } + p.RegisterAccount(platform, accountID) + return SaveOwnerProfile(path, p) +} diff --git a/internal/user/save_test.go b/internal/user/save_test.go new file mode 100644 index 0000000..2e30694 --- /dev/null +++ b/internal/user/save_test.go @@ -0,0 +1,151 @@ +package user + +import ( + "os" + "path/filepath" + "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 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)) + } +}