package runtimeconfig import ( "os" "path/filepath" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) // This file covers the channel-level role binding of design/22 §2 方案 b: the // account's personaId is part of channels.json, so it must survive a save/load // round trip, be omitted when unset (an unbound account is not written as // "personaId": ""), and never be treated as a secret. // TestChannelsPersonaIDRoundTrip proves the personaId is persisted verbatim and // read back on the account it belongs to. func TestChannelsPersonaIDRoundTrip(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "channels.json") accts := map[string][]config.ChannelAccount{ "telegram": {{ID: "a", PersonaID: "yuki", Config: map[string]any{"bot_token": "tk"}}}, } if err := SaveChannels(path, accts); err != nil { t.Fatalf("save: %v", err) } raw, err := os.ReadFile(path) if err != nil { t.Fatalf("read: %v", err) } if !strings.Contains(string(raw), `"personaId": "yuki"`) { t.Fatalf("channels.json missing the bound role: %s", raw) } got, warn := LoadChannels(path, nil) if warn != nil { t.Fatalf("load warn = %v", warn) } if len(got["telegram"]) != 1 || got["telegram"][0].PersonaID != "yuki" { t.Fatalf("round trip personaId = %+v, want yuki", got["telegram"]) } // A channel role is not a secret: masking the credentials must not hide it. if masked := Masked(got["telegram"][0]); masked.PersonaID != "yuki" { t.Fatalf("masked personaId = %q, want yuki", masked.PersonaID) } } // TestChannelsPersonaIDOmittedWhenUnbound locks the omission semantics: an account // with no channel binding writes no personaId field at all, so an existing account // is not silently bound to an empty-named role. func TestChannelsPersonaIDOmittedWhenUnbound(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "channels.json") if err := SaveChannels(path, map[string][]config.ChannelAccount{ "telegram": {{ID: "a", Config: map[string]any{"bot_token": "tk"}}}, }); err != nil { t.Fatalf("save: %v", err) } raw, err := os.ReadFile(path) if err != nil { t.Fatalf("read: %v", err) } if strings.Contains(string(raw), "personaId") { t.Fatalf("unbound account wrote a personaId: %s", raw) } got, _ := LoadChannels(path, nil) if len(got["telegram"]) != 1 || got["telegram"][0].PersonaID != "" { t.Fatalf("loaded personaId = %q, want empty", got["telegram"][0].PersonaID) } } // TestUpsertChannelPersonaID pins the save semantics the panel relies on: an Upsert // carries the whole account, so binding a role stores it, and a later save without // a personaId clears the binding instead of leaving a stale one behind. func TestUpsertChannelPersonaID(t *testing.T) { t.Parallel() accounts := map[string][]config.ChannelAccount{ "telegram": {{ID: "a", Config: map[string]any{"bot_token": "tk"}}}, } bound := UpsertChannel(accounts, "telegram", config.ChannelAccount{ ID: "a", PersonaID: "yuki", Config: map[string]any{"bot_token": "tk"}, }) if bound["telegram"][0].PersonaID != "yuki" { t.Fatalf("upsert personaId = %q, want yuki", bound["telegram"][0].PersonaID) } cleared := UpsertChannel(bound, "telegram", config.ChannelAccount{ ID: "a", Config: map[string]any{"bot_token": "tk"}, }) if cleared["telegram"][0].PersonaID != "" { t.Fatalf("personaId after unbinding save = %q, want empty", cleared["telegram"][0].PersonaID) } }