package config import ( "encoding/json" "reflect" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" "github.com/sipeed/picoclaw/pkg/credential" ) // ─── Test extend structs (simplified, settings + secure in one struct) ─── type testTelegramConfig struct { BaseURL string `json:"base_url" yaml:"-"` Proxy string `json:"proxy" yaml:"-"` UseMarkdownV2 bool `json:"use_markdown_v2" yaml:"-"` Streaming StreamingConfig `json:"streaming,omitempty" yaml:"-"` Token SecureString `json:"token,omitzero" yaml:"token,omitempty"` } type testDiscordConfig struct { MentionOnly bool `json:"mention_only" yaml:"-"` Token SecureString `json:"token,omitzero" yaml:"token,omitempty"` ApiKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty"` } // ═══════════════════════════════════════════════════ // RawNode JSON/YAML round-trip // ═══════════════════════════════════════════════════ func TestRawNode_JSON_RoundTrip(t *testing.T) { t.Run("unmarshal and decode", func(t *testing.T) { var r RawNode require.NoError(t, json.Unmarshal([]byte(`{"key":"value","num":42}`), &r)) assert.False(t, r.IsEmpty()) var m map[string]any require.NoError(t, r.Decode(&m)) assert.Equal(t, "value", m["key"]) assert.Equal(t, float64(42), m["num"]) }) t.Run("marshal round-trip", func(t *testing.T) { r := RawNode(`{"a":1}`) data, err := json.Marshal(r) require.NoError(t, err) assert.JSONEq(t, `{"a":1}`, string(data)) }) t.Run("null input", func(t *testing.T) { var r RawNode require.NoError(t, json.Unmarshal([]byte("null"), &r)) assert.True(t, r.IsEmpty()) data, err := json.Marshal(r) require.NoError(t, err) assert.Equal(t, "null", string(data)) }) t.Run("empty node decode", func(t *testing.T) { var r RawNode var m map[string]any require.NoError(t, r.Decode(&m)) assert.Nil(t, m) }) } func TestRawNode_YAML_RoundTrip(t *testing.T) { t.Run("unmarshal and decode", func(t *testing.T) { var r RawNode require.NoError(t, yaml.Unmarshal([]byte("key: value\nnum: 42"), &r)) assert.False(t, r.IsEmpty()) var m map[string]any require.NoError(t, r.Decode(&m)) assert.Equal(t, "value", m["key"]) }) t.Run("marshal round-trip", func(t *testing.T) { r := RawNode(`{"name":"test"}`) data, err := yaml.Marshal(r) require.NoError(t, err) assert.Contains(t, string(data), "name: test") }) t.Run("empty node marshal", func(t *testing.T) { var r RawNode v, err := yaml.Marshal(r) require.NoError(t, err) assert.Equal(t, "null\n", string(v)) }) } // ═══════════════════════════════════════════════════ // JSON unmarshal: extend.json // ═══════════════════════════════════════════════════ func TestChannel_JSON_Unmarshal(t *testing.T) { jsonData := `{ "enabled": true, "type": "telegram", "allow_from": ["user1", "user2"], "reasoning_channel_id": "-100xxx", "settings": { "base_url": "https://custom-api.example.com", "use_markdown_v2": true, "streaming": {"enabled": true, "throttle_seconds": 2}, "token": "[NOT_HERE]" } }` var ch Channel require.NoError(t, json.Unmarshal([]byte(jsonData), &ch)) assert.True(t, ch.Enabled) assert.Equal(t, "telegram", ch.Type) assert.Equal(t, FlexibleStringSlice{"user1", "user2"}, ch.AllowFrom) assert.Equal(t, "-100xxx", ch.ReasoningChannelID) assert.False(t, ch.SettingsIsEmpty()) // Decode into combined struct var cfg testTelegramConfig require.NoError(t, ch.Decode(&cfg)) assert.Equal(t, "https://custom-api.example.com", cfg.BaseURL) assert.True(t, cfg.UseMarkdownV2) assert.True(t, cfg.Streaming.Enabled) assert.Equal(t, 2, cfg.Streaming.ThrottleSeconds) // SecureString.UnmarshalJSON("[NOT_HERE]") → no-op → empty assert.Equal(t, "", cfg.Token.String()) } func TestStreamingConfig_IsChannelGeneric(t *testing.T) { typ := reflect.TypeOf(StreamingConfig{}) for i := 0; i < typ.NumField(); i++ { field := typ.Field(i) if got := field.Tag.Get("env"); got != "" { t.Fatalf("StreamingConfig.%s env tag = %q, want no channel-specific env tag", field.Name, got) } } } func TestPicoSettings_StreamingConfig(t *testing.T) { raw := RawNode(`{ "token": "test-token", "streaming": { "enabled": true, "throttle_seconds": 2, "min_growth_chars": 80 } }`) ch := &Channel{ Type: ChannelPico, Enabled: true, Settings: raw, } ch.SetName("pico") var picoCfg PicoSettings if err := ch.Decode(&picoCfg); err != nil { t.Fatalf("Decode() error = %v", err) } assert.True(t, picoCfg.Streaming.Enabled) assert.Equal(t, 2, picoCfg.Streaming.ThrottleSeconds) assert.Equal(t, 80, picoCfg.Streaming.MinGrowthChars) } func TestWeComSettings_StreamingConfig(t *testing.T) { raw := RawNode(`{ "bot_id": "bot-1", "streaming": { "enabled": true, "throttle_seconds": 4, "min_growth_chars": 160 } }`) ch := &Channel{ Type: ChannelWeCom, Enabled: true, Settings: raw, } ch.SetName("wecom") var wecomCfg WeComSettings if err := ch.Decode(&wecomCfg); err != nil { t.Fatalf("Decode() error = %v", err) } assert.True(t, wecomCfg.Streaming.Enabled) assert.Equal(t, 4, wecomCfg.Streaming.ThrottleSeconds) assert.Equal(t, 160, wecomCfg.Streaming.MinGrowthChars) } func TestPicoStreamingConfig_Defaults(t *testing.T) { cfg := StreamingConfig{Enabled: true} got := cfg.WithDefaults(1, 40) assert.Equal(t, 1, got.ThrottleSeconds) assert.Equal(t, 40, got.MinGrowthChars) cfg = StreamingConfig{Enabled: true, ThrottleSeconds: 5, MinGrowthChars: 200} got = cfg.WithDefaults(1, 40) assert.Equal(t, 5, got.ThrottleSeconds) assert.Equal(t, 200, got.MinGrowthChars) cfg = StreamingConfig{} got = cfg.WithDefaults(1, 40) assert.Equal(t, 0, got.ThrottleSeconds) assert.Equal(t, 0, got.MinGrowthChars) } func TestInitChannelList_TelegramStreamingEnvCompatibility(t *testing.T) { t.Setenv("PICOCLAW_CHANNELS_TELEGRAM_STREAMING_ENABLED", "true") t.Setenv("PICOCLAW_CHANNELS_TELEGRAM_STREAMING_THROTTLE_SECONDS", "3") t.Setenv("PICOCLAW_CHANNELS_TELEGRAM_STREAMING_MIN_GROWTH_CHARS", "120") channels := ChannelsConfig{ "telegram": { Type: ChannelTelegram, Enabled: true, Settings: RawNode(`{"token":"telegram-token"}`), }, "pico": { Type: ChannelPico, Enabled: true, Settings: RawNode(`{"token":"pico-token"}`), }, } if err := InitChannelList(channels); err != nil { t.Fatalf("InitChannelList() error = %v", err) } tgDecoded, err := channels["telegram"].GetDecoded() if err != nil { t.Fatalf("telegram GetDecoded() error = %v", err) } tgCfg := tgDecoded.(*TelegramSettings) assert.True(t, tgCfg.Streaming.Enabled) assert.Equal(t, 3, tgCfg.Streaming.ThrottleSeconds) assert.Equal(t, 120, tgCfg.Streaming.MinGrowthChars) picoDecoded, err := channels["pico"].GetDecoded() if err != nil { t.Fatalf("pico GetDecoded() error = %v", err) } picoCfg := picoDecoded.(*PicoSettings) assert.False(t, picoCfg.Streaming.Enabled) assert.Equal(t, 0, picoCfg.Streaming.ThrottleSeconds) assert.Equal(t, 0, picoCfg.Streaming.MinGrowthChars) } func TestInitChannelList_RejectsNegativeStreamingDeliveryValues(t *testing.T) { tests := []struct { name string channelType string settings string }{ { name: "pico throttle", channelType: ChannelPico, settings: `{"token":"pico-token","streaming":{"enabled":true,"throttle_seconds":-1}}`, }, { name: "pico growth", channelType: ChannelPico, settings: `{"token":"pico-token","streaming":{"enabled":true,"min_growth_chars":-1}}`, }, { name: "telegram throttle", channelType: ChannelTelegram, settings: `{"token":"telegram-token","streaming":{"enabled":true,"throttle_seconds":-1}}`, }, { name: "telegram growth", channelType: ChannelTelegram, settings: `{"token":"telegram-token","streaming":{"enabled":true,"min_growth_chars":-1}}`, }, { name: "wecom throttle", channelType: ChannelWeCom, settings: `{"bot_id":"bot-1","streaming":{"enabled":true,"throttle_seconds":-1}}`, }, { name: "wecom growth", channelType: ChannelWeCom, settings: `{"bot_id":"bot-1","streaming":{"enabled":true,"min_growth_chars":-1}}`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { channels := ChannelsConfig{ tt.channelType: { Type: tt.channelType, Enabled: true, Settings: RawNode(tt.settings), }, } err := InitChannelList(channels) if err == nil { t.Fatal("InitChannelList() error = nil, want validation error") } if !strings.Contains(err.Error(), "streaming.") { t.Fatalf("InitChannelList() error = %v, want streaming field error", err) } }) } } // ═══════════════════════════════════════════════════ // JSON marshal: secure fields masked as [NOT_HERE] // ═══════════════════════════════════════════════════ func TestChannel_JSON_Marshal_SecureMasked(t *testing.T) { ch := Channel{ Enabled: true, Type: ChannelTelegram, name: "my_telegram", Settings: mustParseRawNode( `{"base_url": "https://api.telegram.org", "proxy": "socks5://127.0.0.1:1080", "token": "123456:SECRET"}`, ), } // Decode to register secure field names var cfg testTelegramConfig require.NoError(t, ch.Decode(&cfg)) data, err := json.MarshalIndent(ch, "", " ") require.NoError(t, err) t.Logf("JSON output:\n%s", string(data)) assert.NotContains(t, string(data), "token") assert.NotContains(t, string(data), "123456:SECRET") assert.NotContains(t, string(data), "SECRET") assert.Contains(t, string(data), "base_url") assert.Contains(t, string(data), "proxy") } func TestChannel_JSON_Marshal_OmitsUnconfiguredStreaming(t *testing.T) { ch := Channel{ Enabled: true, Type: ChannelPico, name: "pico", Settings: mustParseRawNode(`{"ping_interval":30}`), } var cfg PicoSettings require.NoError(t, ch.Decode(&cfg)) data, err := json.MarshalIndent(ch, "", " ") require.NoError(t, err) assert.NotContains(t, string(data), `"streaming"`) } // ═══════════════════════════════════════════════════ // YAML unmarshal: security.yml — only secure data // ═══════════════════════════════════════════════════ func TestChannel_YAML_Unmarshal(t *testing.T) { yamlData := ` settings: token: "789012:XYZ-TOKEN" ` var ch Channel require.NoError(t, yaml.Unmarshal([]byte(yamlData), &ch)) assert.False(t, ch.SettingsIsEmpty()) var cfg testTelegramConfig require.NoError(t, ch.Decode(&cfg)) assert.Equal(t, "789012:XYZ-TOKEN", cfg.Token.String()) assert.Equal(t, "", cfg.BaseURL) } // ═══════════════════════════════════════════════════ // YAML marshal: only secure fields // ═══════════════════════════════════════════════════ func TestChannel_YAML_Marshal_OnlySecureFields(t *testing.T) { ch := Channel{ Enabled: true, Type: ChannelTelegram, name: "my_telegram", Settings: mustParseRawNode(`{"base_url": "https://api.telegram.org", "token": "123456:SECRET"}`), } var cfg testTelegramConfig require.NoError(t, ch.Decode(&cfg)) data, err := yaml.Marshal(ch) require.NoError(t, err) t.Logf("YAML output:\n%s", string(data)) assert.NotContains(t, string(data), "NOT_HERE") assert.Contains(t, string(data), "token") assert.Contains(t, string(data), "123456:SECRET") // Non-secure fields must NOT appear in YAML output assert.NotContains(t, string(data), "base_url") assert.NotContains(t, string(data), "proxy") } // ═══════════════════════════════════════════════════ // extractSecureFieldNames // ═══════════════════════════════════════════════════ func TestExtractSecureFieldNames(t *testing.T) { t.Run("telegram extend", func(t *testing.T) { names := extractSecureFieldNames(&testTelegramConfig{}) assert.Equal(t, map[string]struct{}{"token": {}}, names) }) t.Run("discord extend", func(t *testing.T) { names := extractSecureFieldNames(&testDiscordConfig{}) assert.Equal(t, map[string]struct{}{"token": {}, "api_keys": {}}, names) }) t.Run("non-struct target", func(t *testing.T) { names := extractSecureFieldNames("not a struct") assert.Nil(t, names) }) t.Run("struct without secure fields", func(t *testing.T) { type NoSecure struct { Name string `json:"name"` Count int `json:"count"` } names := extractSecureFieldNames(&NoSecure{}) assert.Empty(t, names) }) } // ═══════════════════════════════════════════════════ // mergeRawJSON // ═══════════════════════════════════════════════════ func TestMergeRawJSON(t *testing.T) { t.Run("overlay overrides base", func(t *testing.T) { base := RawNode(`{"base_url": "old", "token": "[NOT_HERE]"}`) overlay := RawNode(`{"token": "REAL_TOKEN"}`) merged, err := mergeRawJSON(base, overlay) require.NoError(t, err) var m map[string]any json.Unmarshal(merged, &m) assert.Equal(t, "old", m["base_url"]) assert.Equal(t, "REAL_TOKEN", m["token"]) }) t.Run("empty overlay", func(t *testing.T) { base := RawNode(`{"base_url": "https://api.telegram.org"}`) merged, err := mergeRawJSON(base, nil) require.NoError(t, err) // mergeRawJSON normalizes JSON through unmarshal→marshal, so compare parsed values var orig, result map[string]any json.Unmarshal(base, &orig) json.Unmarshal(merged, &result) assert.Equal(t, orig, result) }) t.Run("empty base", func(t *testing.T) { overlay := RawNode(`{"token": "NEW"}`) merged, err := mergeRawJSON(nil, overlay) require.NoError(t, err) assert.Contains(t, string(merged), `"token":"NEW"`) }) } // ═══════════════════════════════════════════════════ // Full flow: extend.json + security.yml merge // ═══════════════════════════════════════════════════ func TestChannel_FullFlow_JSON_YAML_Merge(t *testing.T) { // Step 1: Load from extend.json jsonData := `{ "enabled": true, "type": "telegram", "allow_from": ["admin"], "settings": { "base_url": "https://custom-api.example.com", "use_markdown_v2": true, "streaming": {"enabled": true}, "token": "[NOT_HERE]" } }` var ch Channel require.NoError(t, json.Unmarshal([]byte(jsonData), &ch)) assert.True(t, ch.Enabled) // Step 2: Load secure from security.yml yamlData := ` settings: token: "123456:REAL-TOKEN" ` //var yamlOverlay struct { // Settings RawNode `yaml:"settings"` //} require.NoError(t, yaml.Unmarshal([]byte(yamlData), &ch)) // Step 3: Merge // require.NoError(t, ch.MergeSecure(yamlOverlay.Settings)) // Step 4: Decode merged result var cfg testTelegramConfig require.NoError(t, ch.Decode(&cfg)) assert.Equal(t, "https://custom-api.example.com", cfg.BaseURL) assert.True(t, cfg.UseMarkdownV2) assert.Equal(t, "123456:REAL-TOKEN", cfg.Token.String()) // Step 5: Save extend.json → token masked as [NOT_HERE] outJSON, err := json.MarshalIndent(ch, "", " ") require.NoError(t, err) t.Logf("Saved extend.json:\n%s", string(outJSON)) assert.NotContains(t, string(outJSON), "token") assert.NotContains(t, string(outJSON), "REAL-TOKEN") assert.Contains(t, string(outJSON), "base_url") // Step 6: Save security.yml → only token outYAML, err := yaml.Marshal(ch) require.NoError(t, err) t.Logf("Saved security.yml:\n%s", string(outYAML)) assert.Contains(t, string(outYAML), "123456:REAL-TOKEN") assert.NotContains(t, string(outYAML), "NOT_HERE") assert.NotContains(t, string(outYAML), "base_url") } // ═══════════════════════════════════════════════════ // Multiple channels in a list // ═══════════════════════════════════════════════════ func TestChannel_MultipleChannels(t *testing.T) { type ChannelsWrapper struct { Channels ChannelsConfig `json:"channels" yaml:"channels"` } jsonData := `{ "channels": { "tg1": { "enabled": true, "type": "telegram", "settings": {"base_url": "https://api.telegram.org", "token": "[NOT_HERE]"} }, "tg2": { "enabled": true, "type": "telegram", "settings": {"base_url": "https://custom-api.example.com", "proxy": "socks5://proxy:1080", "token": "[NOT_HERE]"} }, "discord1": { "enabled": true, "type": "discord", "settings": {"mention_only": true, "token": "[NOT_HERE]"} } } }` var wrapper ChannelsWrapper require.NoError(t, json.Unmarshal([]byte(jsonData), &wrapper)) require.Len(t, wrapper.Channels, 3) // Decode each channel to register secure field names for name, ch := range wrapper.Channels { ch.SetName(name) // Set channel name switch ch.Type { case "telegram": var tc testTelegramConfig require.NoError(t, ch.Decode(&tc)) case "discord": var dc testDiscordConfig require.NoError(t, ch.Decode(&dc)) default: t.Logf("Unknown channel type: %s for channel %s", ch.Type, name) } } // Load secrets from YAML yamlData := ` channels: tg1: settings: token: "TOKEN_1" tg2: settings: token: "TOKEN_2" discord1: settings: token: "DISCORD_TOKEN" ` require.NoError(t, yaml.Unmarshal([]byte(yamlData), &wrapper)) // Verify first telegram var tg1 testTelegramConfig require.NoError(t, wrapper.Channels["tg1"].Decode(&tg1)) assert.Equal(t, "https://api.telegram.org", tg1.BaseURL) assert.Equal(t, "TOKEN_1", tg1.Token.String()) // Verify second telegram var tg2 testTelegramConfig require.NoError(t, wrapper.Channels["tg2"].Decode(&tg2)) assert.Equal(t, "https://custom-api.example.com", tg2.BaseURL) assert.Equal(t, "socks5://proxy:1080", tg2.Proxy) assert.Equal(t, "TOKEN_2", tg2.Token.String()) // Verify discord var disc testDiscordConfig require.NoError(t, wrapper.Channels["discord1"].Decode(&disc)) assert.True(t, disc.MentionOnly) assert.Equal(t, "DISCORD_TOKEN", disc.Token.String()) // Save JSON → all tokens removed outJSON, err := json.MarshalIndent(wrapper, "", " ") require.NoError(t, err) t.Logf("Saved extend.json:\n%s", string(outJSON)) assert.NotContains(t, string(outJSON), "token") assert.NotContains(t, string(outJSON), "TOKEN_1") assert.NotContains(t, string(outJSON), "DISCORD_TOKEN") // Save YAML → only tokens outYAML, err := yaml.Marshal(wrapper) require.NoError(t, err) t.Logf("Saved security.yml:\n%s", string(outYAML)) assert.Contains(t, string(outYAML), "TOKEN_1") assert.Contains(t, string(outYAML), "DISCORD_TOKEN") assert.NotContains(t, string(outYAML), "base_url") assert.NotContains(t, string(outYAML), "NOT_HERE") } // ═══════════════════════════════════════════════════ // Empty/missing settings // ═══════════════════════════════════════════════════ func TestChannel_EmptySettings(t *testing.T) { // Flat format with only common fields: enabled and type are extracted to Channel, // Settings should be empty (no channel-specific fields) jsonData := `{ "enabled": true, "type": "telegram" }` var ch Channel require.NoError(t, json.Unmarshal([]byte(jsonData), &ch)) // All fields are common fields — Settings should be empty assert.True(t, ch.SettingsIsEmpty()) // Decode into typed config — common fields like enabled/type are extracted, // channel-specific fields should be empty var cfg testTelegramConfig require.NoError(t, ch.Decode(&cfg)) assert.Equal(t, "", cfg.BaseURL) assert.Equal(t, "", cfg.Token.String()) } func TestChannel_NestedEmptySettings(t *testing.T) { // Nested format with empty settings jsonData := `{ "enabled": true, "type": "telegram", "settings": {} }` var ch Channel require.NoError(t, json.Unmarshal([]byte(jsonData), &ch)) assert.True(t, ch.SettingsIsEmpty()) var cfg testTelegramConfig require.NoError(t, ch.Decode(&cfg)) assert.Equal(t, "", cfg.BaseURL) assert.Equal(t, "", cfg.Token.String()) } // ═══════════════════════════════════════════════════ // YAML merge with fewer channels than JSON // ═══════════════════════════════════════════════════ func TestChannel_MultipleChannels_PartialYAMLMerge(t *testing.T) { type ChannelsWrapper struct { Channels ChannelsConfig `json:"channels" yaml:"channels"` } // JSON has 3 channels jsonData := `{ "channels": { "tg1": {"enabled": true, "type": "telegram", "settings": {"base_url": "https://api.telegram.org", "token": "[NOT_HERE]"}}, "tg2": {"enabled": true, "type": "telegram", "settings": {"base_url": "https://custom-api.example.com", "token": "[NOT_HERE]"}}, "discord1": {"enabled": true, "type": "discord", "settings": {"mention_only": true, "token": "[NOT_HERE]"}} } }` var wrapper ChannelsWrapper require.NoError(t, json.Unmarshal([]byte(jsonData), &wrapper)) require.Len(t, wrapper.Channels, 3) t.Logf("wrapper: %v", wrapper) // YAML has only 2 secrets (missing tg2) yamlData := ` channels: tg1: settings: token: "TOKEN_1" discord1: settings: token: "DISCORD_TOKEN" ` //var yamlWrapper struct { // Channels map[string]struct { // Settings RawNode `yaml:"settings"` // } `yaml:"channels"` //} assert.True(t, wrapper.Channels["tg1"].Enabled) assert.Equal(t, "telegram", wrapper.Channels["tg1"].Type) require.NoError(t, yaml.Unmarshal([]byte(yamlData), &wrapper)) t.Logf("yamlWrapper: %v", wrapper) require.Len(t, wrapper.Channels, 3) assert.True(t, wrapper.Channels["tg1"].Enabled) t.Logf("wrapper: %v", string(wrapper.Channels["tg1"].Settings)) //// Merge by name; missing keys are simply absent from the YAML map (no-op) //for name, ch := range wrapper.Channels { // if overlay, ok := yamlWrapper.Channels[name]; ok { // require.NoError(t, ch.MergeSecure(overlay.Settings)) // } //} // tg1: merged from YAML var tg1 TelegramSettings require.NoError(t, wrapper.Channels["tg1"].Decode(&tg1)) assert.Equal(t, "TOKEN_1", tg1.Token.String()) // tg2: no YAML entry → MergeSecure not called → token stays [NOT_HERE] → empty var tg2 TelegramSettings require.NoError(t, wrapper.Channels["tg2"].Decode(&tg2)) assert.Equal(t, "", tg2.Token.String()) assert.Equal(t, "https://custom-api.example.com", tg2.BaseURL) // discord1: merged from YAML var disc DiscordSettings require.NoError(t, wrapper.Channels["discord1"].Decode(&disc)) assert.Equal(t, "DISCORD_TOKEN", disc.Token.String()) assert.True(t, disc.MentionOnly) } // ═══════════════════════════════════════════════════ // YAML list: channels with secure data // ═══════════════════════════════════════════════════ func TestChannel_YAML_ListWithSecure(t *testing.T) { yamlData := ` channels: tg_bot: enabled: true type: telegram settings: token: "TG_TOKEN_FROM_YAML" discord_bot: enabled: true type: discord settings: token: "DISCORD_TOKEN_FROM_YAML" ` type ChannelsWrapper struct { Channels map[string]*Channel `yaml:"channels"` } var wrapper ChannelsWrapper require.NoError(t, yaml.Unmarshal([]byte(yamlData), &wrapper)) require.Len(t, wrapper.Channels, 2) var tg testTelegramConfig require.NoError(t, wrapper.Channels["tg_bot"].Decode(&tg)) assert.Equal(t, "TG_TOKEN_FROM_YAML", tg.Token.String()) var disc testDiscordConfig require.NoError(t, wrapper.Channels["discord_bot"].Decode(&disc)) assert.Equal(t, "DISCORD_TOKEN_FROM_YAML", disc.Token.String()) } // ═══════════════════════════════════════════════════ // removeSecureFields / filterSecureFields unit tests // ═══════════════════════════════════════════════════ func TestRemoveSecureFields(t *testing.T) { t.Run("removes known secure fields", func(t *testing.T) { r := RawNode(`{"base_url": "https://api.telegram.org", "token": "SECRET"}`) names := map[string]struct{}{"token": {}} cleaned := removeSecureFields(r, names) var m map[string]any json.Unmarshal(cleaned, &m) assert.Equal(t, "https://api.telegram.org", m["base_url"]) assert.NotContains(t, m, "token") }) t.Run("nil secureFields returns as-is", func(t *testing.T) { r := RawNode(`{"token": "SECRET"}`) cleaned := removeSecureFields(r, nil) assert.Equal(t, string(r), string(cleaned)) }) t.Run("empty raw returns as-is", func(t *testing.T) { cleaned := removeSecureFields(nil, map[string]struct{}{"token": {}}) assert.Nil(t, cleaned) }) } func TestFilterSecureFields(t *testing.T) { t.Run("keeps only secure fields", func(t *testing.T) { r := RawNode(`{"base_url": "https://api.telegram.org", "token": "SECRET"}`) names := map[string]struct{}{"token": {}} filtered := filterSecureFields(r, names) var m map[string]any json.Unmarshal(filtered, &m) assert.NotContains(t, m, "base_url") assert.Equal(t, "SECRET", m["token"]) }) t.Run("nil secureFields returns nil", func(t *testing.T) { r := RawNode(`{"token": "SECRET"}`) filtered := filterSecureFields(r, nil) assert.Nil(t, filtered) }) t.Run("empty raw returns nil", func(t *testing.T) { filtered := filterSecureFields(nil, map[string]struct{}{"token": {}}) assert.Nil(t, filtered) }) } // ═══════════════════════════════════════════════════ // SecureStrings (ApiKeys) full flow // ═══════════════════════════════════════════════════ func TestChannel_SecureStrings_ApiKeys(t *testing.T) { // Step 1: Load from extend.json jsonData := `{ "enabled": true, "type": "discord", "settings": { "mention_only": true, "token": "[NOT_HERE]", "api_keys": ["[NOT_HERE]"] } }` var ch Channel require.NoError(t, json.Unmarshal([]byte(jsonData), &ch)) // Step 2: Merge secure from security.yml yamlData := ` settings: token: "DISCORD_BOT_TOKEN" api_keys: - "KEY_1" - "KEY_2" ` require.NoError(t, yaml.Unmarshal([]byte(yamlData), &ch)) // Step 3: Decode — both SecureString and SecureStrings should be populated var cfg testDiscordConfig require.NoError(t, ch.Decode(&cfg)) assert.True(t, cfg.MentionOnly) assert.Equal(t, "DISCORD_BOT_TOKEN", cfg.Token.String()) require.Len(t, cfg.ApiKeys, 2) assert.Equal(t, "KEY_1", cfg.ApiKeys[0].String()) assert.Equal(t, "KEY_2", cfg.ApiKeys[1].String()) // Step 4: Save extend.json — both secure fields removed outJSON, err := json.MarshalIndent(ch, "", " ") require.NoError(t, err) t.Logf("Saved extend.json:\n%s", string(outJSON)) assert.NotContains(t, string(outJSON), "token") assert.NotContains(t, string(outJSON), "api_keys") assert.NotContains(t, string(outJSON), "DISCORD_BOT_TOKEN") assert.NotContains(t, string(outJSON), "KEY") assert.Contains(t, string(outJSON), "mention_only") // Step 5: Save security.yml — only secure fields outYAML, err := yaml.Marshal(ch) require.NoError(t, err) t.Logf("Saved security.yml:\n%s", string(outYAML)) assert.Contains(t, string(outYAML), "DISCORD_BOT_TOKEN") assert.Contains(t, string(outYAML), "KEY_1") assert.Contains(t, string(outYAML), "KEY_2") assert.NotContains(t, string(outYAML), "mention_only") assert.NotContains(t, string(outYAML), "NOT_HERE") } func TestChannel_SecureStrings_ApiKeys_EmptyInJSON(t *testing.T) { // JSON has no api_keys field jsonData := `{ "enabled": true, "type": "discord", "settings": { "mention_only": true, "token": "[NOT_HERE]" } }` var ch Channel require.NoError(t, json.Unmarshal([]byte(jsonData), &ch)) // Merge with api_keys from YAML yamlData := ` settings: token: "MY_TOKEN" api_keys: - "KEY_A" ` require.NoError(t, yaml.Unmarshal([]byte(yamlData), &ch)) var cfg testDiscordConfig require.NoError(t, ch.Decode(&cfg)) assert.Equal(t, "MY_TOKEN", cfg.Token.String()) require.Len(t, cfg.ApiKeys, 1) assert.Equal(t, "KEY_A", cfg.ApiKeys[0].String()) } func TestChannel_SecureStrings_ApiKeys_NoMerge(t *testing.T) { // JSON only, no merge — SecureStrings should be empty jsonData := `{ "enabled": true, "type": "discord", "settings": { "mention_only": true, "token": "[NOT_HERE]", "api_keys": ["[NOT_HERE]"] } }` var ch Channel require.NoError(t, json.Unmarshal([]byte(jsonData), &ch)) var cfg testDiscordConfig require.NoError(t, ch.Decode(&cfg)) assert.True(t, cfg.MentionOnly) assert.Equal(t, "", cfg.Token.String()) // ["[NOT_HERE]"] entries are filtered out → nil assert.Nil(t, cfg.ApiKeys) } // ═══════════════════════════════════════════════════ // enc:// token: encrypt → store → merge → decrypt // ═══════════════════════════════════════════════════ func TestChannel_EncryptedToken(t *testing.T) { mustSetupSSHKey(t) const testPassphrase = "test-passphrase-123" const plainToken = "123456:MY-SECRET-TOKEN" // Encrypt the token to get an enc:// string encrypted, err := credential.Encrypt(testPassphrase, "", plainToken) require.NoError(t, err) require.True(t, strings.HasPrefix(encrypted, "enc://"), "expected enc:// prefix, got: %s", encrypted) t.Logf("encrypted token: %s", encrypted) // Replace PassphraseProvider so SecureString.fromRaw can decrypt orig := credential.PassphraseProvider credential.PassphraseProvider = func() string { return testPassphrase } t.Cleanup(func() { credential.PassphraseProvider = orig }) // Step 1: Load from extend.json (token is [NOT_HERE]) jsonData := `{ "enabled": true, "type": "telegram", "settings": { "base_url": "https://api.telegram.org", "use_markdown_v2": true, "token": "[NOT_HERE]" } }` var ch Channel require.NoError(t, json.Unmarshal([]byte(jsonData), &ch)) // ── Scenario: security.yml stores enc:// token ── yamlData := ` settings: token: ` + encrypted + ` ` // Step 2: Merge enc:// token from security.yml require.NoError(t, yaml.Unmarshal([]byte(yamlData), &ch)) // Step 3: Decode — SecureString.fromRaw resolves enc:// → plaintext var cfg testTelegramConfig require.NoError(t, ch.Decode(&cfg)) assert.Equal(t, "https://api.telegram.org", cfg.BaseURL) assert.True(t, cfg.UseMarkdownV2) // The key assertion: enc:// is decrypted to the original plaintext assert.Equal(t, plainToken, cfg.Token.String(), "SecureString should resolve enc:// to the original plaintext token") // Step 4: Save extend.json → token masked as [NOT_HERE] outJSON, err := json.MarshalIndent(ch, "", " ") require.NoError(t, err) assert.NotContains(t, string(outJSON), "token") assert.NotContains(t, string(outJSON), plainToken) assert.NotContains(t, string(outJSON), "enc://") // Step 5: Save security.yml → token preserved as enc:// outYAML, err := yaml.Marshal(ch) require.NoError(t, err) t.Logf("Saved security.yml:\n%s", string(outYAML)) assert.Contains(t, string(outYAML), encrypted) assert.NotContains(t, string(outYAML), plainToken) assert.NotContains(t, string(outYAML), "NOT_HERE") assert.NotContains(t, string(outYAML), "base_url") } // ═══════════════════════════════════════════════════ // enc:// token directly in extend.json (edge case) // ═══════════════════════════════════════════════════ func TestChannel_EncryptedTokenInJSON(t *testing.T) { mustSetupSSHKey(t) const testPassphrase = "json-enc-passphrase" const plainToken = "BOT-TOKEN-FROM-JSON" const plainToken2 = "new token2" encrypted, err := credential.Encrypt(testPassphrase, "", plainToken) require.NoError(t, err) orig := credential.PassphraseProvider credential.PassphraseProvider = func() string { return testPassphrase } t.Cleanup(func() { credential.PassphraseProvider = orig }) // extend.json with enc:// token directly (no merge needed) jsonData := `{ "enabled": true, "type": "telegram", "settings": { "base_url": "https://api.telegram.org", "token": ` + `"` + encrypted + `"` + ` } }` t.Logf("JSON data:\n%s", jsonData) var ch Channel require.NoError(t, json.Unmarshal([]byte(jsonData), &ch)) var cfg testTelegramConfig require.NoError(t, ch.Decode(&cfg)) assert.Equal(t, plainToken, cfg.Token.String(), "enc:// token in JSON should be decrypted correctly") cfg.Token.Set(plainToken2) // No explicit Encode needed — Decode stored &cfg, so modifications are // automatically reflected in MarshalJSON/MarshalYAML. // Save JSON → masked as [NOT_HERE] outJSON, err := json.MarshalIndent(ch, "", " ") require.NoError(t, err) t.Logf("Saved extend.json:\n%s", string(outJSON)) assert.NotContains(t, string(outJSON), "token") assert.NotContains(t, string(outJSON), plainToken2) assert.NotContains(t, string(outJSON), "enc://") // Save YAML → only token, re-encrypted outYAML, err := yaml.Marshal(ch) require.NoError(t, err) t.Logf("Saved security.yml:\n%s", string(outYAML)) // MarshalYAML re-encrypts with a new random salt/nonce, so verify via round-trip assert.Contains(t, string(outYAML), "enc://") // Round-trip: unmarshal YAML output through Channel and verify decryption var ch2 Channel require.NoError(t, yaml.Unmarshal(outYAML, &ch2)) var cfg2 testTelegramConfig require.NoError(t, ch2.Decode(&cfg2)) assert.Equal(t, plainToken2, cfg2.Token.String()) } // ═══════════════════════════════════════════════════ // enc:// token with missing passphrase → error // ═══════════════════════════════════════════════════ func TestChannel_EncryptedToken_NoPassphrase(t *testing.T) { mustSetupSSHKey(t) const testPassphrase = "will-be-removed" encrypted, err := credential.Encrypt(testPassphrase, "", "secret-token") require.NoError(t, err) // Ensure no passphrase is available orig := credential.PassphraseProvider credential.PassphraseProvider = func() string { return "" } t.Cleanup(func() { credential.PassphraseProvider = orig }) jsonData := `{ "enabled": true, "type": "telegram", "settings": { "base_url": "https://api.telegram.org", "token": ` + `"` + encrypted + `"` + ` } }` var ch Channel require.NoError(t, json.Unmarshal([]byte(jsonData), &ch)) var cfg testTelegramConfig // Decode should fail because enc:// cannot be decrypted without passphrase err = ch.Decode(&cfg) require.Error(t, err) assert.Contains(t, err.Error(), "passphrase required") } // ─── helper ─── func mustParseRawNode(s string) RawNode { return RawNode(s) }