Files
picoclaw/pkg/config/security_test.go
T
Cytown 667fc85d54 refactor(config): make config.Channel to multiple instance support
add new field type to Channel struct
config.channels refactor to channel_list
update config version to 3
update the docs
2026-04-13 22:21:21 +08:00

273 lines
7.8 KiB
Go

// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package config
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/caarlos0/env/v11"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
func TestSecurityConfig(t *testing.T) {
t.Run("LoadNonExistent", func(t *testing.T) {
sec := &Config{Channels: make(ChannelsConfig)}
err := loadSecurityConfig(sec, "/nonexistent/.security.yml")
require.NoError(t, err)
assert.NotNil(t, sec)
assert.Empty(t, sec.ModelList)
assert.NotNil(t, sec.Channels)
assert.NotNil(t, sec.Tools.Web)
assert.NotNil(t, sec.Tools.Skills)
})
}
func TestSecurityPath(t *testing.T) {
tests := []struct {
name string
configDir string
want string
}{
{
name: "standard path",
configDir: "/home/user/.picoclaw/config.json",
want: "/home/user/.picoclaw/.security.yml",
},
{
name: "nested path",
configDir: "/path/to/config/myconfig.json",
want: "/path/to/config/.security.yml",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := securityPath(tt.configDir)
assert.Equal(t, tt.want, got)
})
}
}
func TestSaveAndLoadSecurityConfig(t *testing.T) {
t.Run("test for securestring", func(t *testing.T) {
type testStruct struct {
Secret SecureString `json:"secret,omitzero" yaml:"secret,omitempty" env:"TEST_SECURE_STRING"`
}
s := testStruct{Secret: *NewSecureString("test")}
out, err := yaml.Marshal(s) // 直接对 SecureString 进行序列化
require.NoError(t, err)
t.Logf("output: %v", string(out))
assert.Equal(t, "secret: test\n", string(out))
out, err = json.Marshal(s)
require.NoError(t, err)
t.Logf("output: %v", string(out))
assert.Equal(t, "{}", string(out))
})
tmpDir := t.TempDir()
secPath := filepath.Join(tmpDir, SecurityConfigFile)
original := &Config{
Version: CurrentVersion,
ModelList: SecureModelList{
{
ModelName: "model1",
Model: "test/model",
APIBase: "api.example.com",
APIKeys: SecureStrings{NewSecureString("key1"), NewSecureString("key2")},
},
{
ModelName: "model2",
Model: "test/model2",
APIBase: "api2.example.com",
APIKeys: SecureStrings{NewSecureString("model2_key")},
},
},
Tools: ToolsConfig{
Web: WebToolsConfig{
Brave: BraveConfig{
Enabled: true,
APIKeys: SecureStrings{NewSecureString("brave_key")},
},
},
Skills: SkillsToolsConfig{
Github: SkillsGithubConfig{
Token: *NewSecureString("github_token"),
Proxy: "test proxy",
},
},
},
Channels: func() ChannelsConfig {
chs := make(ChannelsConfig)
type def struct {
name string
raw string // raw JSON with actual secure values (bypasses SecureString.MarshalJSON)
}
for _, d := range []def{
{"telegram", `{"enabled":true,"settings":{"token":"telegram_token"}}`},
{"feishu", `{"enabled":true,"settings":{"app_id":"feishu_app_id","app_secret":"feishu_app_secret"}}`},
{"discord", `{"enabled":true,"settings":{"token":"discord_token"}}`},
{"qq", `{"enabled":true,"settings":{"app_secret":"qq_app_secret"}}`},
{"pico_client", `{"enabled":true,"settings":{"token":"pico_client_token"}}`},
} {
bc := &Channel{}
json.Unmarshal([]byte(d.raw), bc)
bc.Type = d.name
switch bc.Type {
case "qq":
bc.Decode(&QQSettings{})
case "telegram":
bc.Decode(&TelegramSettings{})
case "discord":
bc.Decode(&DiscordSettings{})
case "feishu":
bc.Decode(&FeishuSettings{})
case "pico_client":
bc.Decode(&PicoClientSettings{})
}
chs[d.name] = bc
}
return chs
}(),
}
t.Run("test for original", func(t *testing.T) {
assert.Equal(t, 2, len(original.ModelList[0].APIKeys))
assert.Equal(t, "key1", original.ModelList[0].APIKeys[0].String())
})
cfg2 := &Config{}
t.Run("test for json", func(t *testing.T) {
marshal, err := json.Marshal(original)
require.NoError(t, err)
t.Logf("json: %s", string(marshal))
assert.NotContains(t, string(marshal), "\"api_keys\"")
assert.NotContains(t, string(marshal), notHere)
err = json.Unmarshal(marshal, cfg2)
require.NoError(t, err)
require.Equal(t, 2, len(cfg2.ModelList))
assert.Empty(t, cfg2.ModelList[0].APIKeys)
assert.Empty(t, cfg2.ModelList[1].APIKeys)
})
t.Run("test for save yaml", func(t *testing.T) {
// Save
err := saveSecurityConfig(secPath, original)
require.NoError(t, err)
// Verify file was created with correct permissions
info, err := os.Stat(secPath)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o600), info.Mode())
file, err := os.ReadFile(secPath)
assert.NoError(t, err)
t.Logf("%s", string(file))
// Parse saved YAML and verify channelTestSaveConfig_EncryptsPlaintextAPIKey secure fields are present
var saved struct {
ChannelList map[string]map[string]any `yaml:"channel_list"`
}
require.NoError(t, yaml.Unmarshal(file, &saved))
channels := saved.ChannelList
getSetting := func(name string) map[string]any {
return channels[name]["settings"].(map[string]any)
}
assert.Contains(t, getSetting("telegram")["token"], "telegram_token")
assert.Contains(t, getSetting("feishu")["app_secret"], "feishu_app_secret")
assert.Contains(t, getSetting("discord")["token"], "discord_token")
assert.Contains(t, getSetting("qq")["app_secret"], "qq_app_secret")
assert.Contains(t, getSetting("pico_client")["token"], "pico_client_token")
// Rewrite file with deterministic content for load test (use channel_list)
yamlOutput := `channel_list:
telegram:
token: telegram_token
feishu:
app_secret: feishu_app_secret
discord:
token: discord_token
qq:
app_secret: qq_app_secret
pico_client:
token: pico_client_token
model_list:
model1:0:
api_keys:
- key1
- key2
model2:0:
api_keys:
- model2_key
web:
brave:
api_keys:
- brave_key
skills:
github:
token: github_token
`
err = os.WriteFile(secPath, []byte(yamlOutput), 0o600)
require.NoError(t, err)
})
t.Run("test for load yaml", func(t *testing.T) {
// Load
cfg := cfg2
err := loadSecurityConfig(cfg, secPath)
require.NoError(t, err)
t.Logf("%+v", cfg)
t.Logf("%+v", cfg.Tools.Web.Brave.APIKeys)
t.Logf("%+v", cfg.Tools.Skills.Github.Token)
require.EqualValues(t, 2, len(cfg.ModelList))
assert.Equal(t, "key1", cfg.ModelList[0].APIKeys[0].String())
assert.Equal(t, "key2", cfg.ModelList[0].APIKeys[1].String())
assert.Equal(t, "model2_key", cfg.ModelList[1].APIKeys[0].String())
assert.EqualValues(t, original.Tools.Web.Brave.APIKeys, cfg.Tools.Web.Brave.APIKeys)
})
t.Run("test for env overwrite", func(t *testing.T) {
// This will throw a COMPILER ERROR if SecureString doesn't
// correctly implement the yaml.Marshaler interface.
var _ yaml.Marshaler = (*SecureString)(nil)
// If you are using Value types in your config, also check:
var _ yaml.Marshaler = SecureString{}
// Set up a fresh config with a qq channel
envCfg := &Config{
Channels: ChannelsConfig{
"qq": {
Enabled: true,
Type: "qq",
Settings: RawNode(`{"enabled":true,"app_secret":"qq_app_secret"}`),
},
},
Tools: original.Tools,
}
t.Setenv("PICOCLAW_CHANNELS_QQ_APP_SECRET", "qq_app_secret_env")
t.Setenv("PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS", "brave_key_env,abc")
require.NoError(t, env.Parse(envCfg))
// Channel env overrides need explicit handling since ChannelsConfig is map-based
require.NoError(t, InitChannelList(envCfg.Channels))
bc := envCfg.Channels.Get("qq")
decoded, err := bc.GetDecoded()
require.NoError(t, err)
qqCfg := decoded.(*QQSettings)
assert.Equal(t, "qq_app_secret_env", qqCfg.AppSecret.raw)
assert.Equal(t, "brave_key_env", envCfg.Tools.Web.Brave.APIKeys[0].raw)
assert.Equal(t, "abc", envCfg.Tools.Web.Brave.APIKeys[1].raw)
})
}