Files
picoclaw/pkg/config/config_channel_test.go
lxowalle 639b32703a feat: support streaming (#2892)
* Support streaming

* fix: stream pico reasoning updates

Route Pico reasoning through the active streamer and hide empty thought placeholders.

* fix: harden configured streaming delivery

* fix ci

* fix split issue
2026-05-19 16:38:47 +08:00

1107 lines
36 KiB
Go

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