419 lines
10 KiB
Go
419 lines
10 KiB
Go
// FILE: lixenwraith/config/dynamic_test.go
|
|
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// TestMultiFormatLoading tests loading different config formats
|
|
func TestMultiFormatLoading(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create test config in different formats
|
|
tomlConfig := `
|
|
[server]
|
|
host = "toml-host"
|
|
port = 8080
|
|
|
|
[database]
|
|
url = "postgres://localhost/toml"
|
|
`
|
|
|
|
jsonConfig := `{
|
|
"server": {
|
|
"host": "json-host",
|
|
"port": 9090
|
|
},
|
|
"database": {
|
|
"url": "postgres://localhost/json"
|
|
}
|
|
}`
|
|
|
|
yamlConfig := `
|
|
server:
|
|
host: yaml-host
|
|
port: 7070
|
|
database:
|
|
url: postgres://localhost/yaml
|
|
`
|
|
|
|
// Write config files
|
|
tomlPath := filepath.Join(tmpDir, "config.toml")
|
|
jsonPath := filepath.Join(tmpDir, "config.json")
|
|
yamlPath := filepath.Join(tmpDir, "config.yaml")
|
|
|
|
require.NoError(t, os.WriteFile(tomlPath, []byte(tomlConfig), 0644))
|
|
require.NoError(t, os.WriteFile(jsonPath, []byte(jsonConfig), 0644))
|
|
require.NoError(t, os.WriteFile(yamlPath, []byte(yamlConfig), 0644))
|
|
|
|
t.Run("AutoDetectFormats", func(t *testing.T) {
|
|
cfg := New()
|
|
cfg.Register("server.host", "")
|
|
cfg.Register("server.port", 0)
|
|
cfg.Register("database.url", "")
|
|
|
|
// Test TOML
|
|
cfg.SetFileFormat("auto")
|
|
require.NoError(t, cfg.LoadFile(tomlPath))
|
|
host, _ := cfg.Get("server.host")
|
|
assert.Equal(t, "toml-host", host)
|
|
|
|
// Test JSON
|
|
require.NoError(t, cfg.LoadFile(jsonPath))
|
|
host, _ = cfg.Get("server.host")
|
|
assert.Equal(t, "json-host", host)
|
|
port, _ := cfg.Get("server.port")
|
|
// JSON number should be preserved as json.Number but convertible
|
|
switch v := port.(type) {
|
|
case json.Number:
|
|
// Expected for raw value
|
|
assert.Equal(t, json.Number("9090"), v)
|
|
case int64:
|
|
// Expected after decode hook conversion
|
|
assert.Equal(t, int64(9090), v)
|
|
case float64:
|
|
// Alternative conversion
|
|
assert.Equal(t, float64(9090), v)
|
|
default:
|
|
t.Errorf("Unexpected type for port: %T", port)
|
|
}
|
|
|
|
// Test YAML
|
|
require.NoError(t, cfg.LoadFile(yamlPath))
|
|
host, _ = cfg.Get("server.host")
|
|
assert.Equal(t, "yaml-host", host)
|
|
})
|
|
|
|
t.Run("ExplicitFormat", func(t *testing.T) {
|
|
cfg := New()
|
|
cfg.Register("server.host", "")
|
|
|
|
// Force JSON parsing on .conf file
|
|
confPath := filepath.Join(tmpDir, "config.conf")
|
|
require.NoError(t, os.WriteFile(confPath, []byte(jsonConfig), 0644))
|
|
|
|
cfg.SetFileFormat("json")
|
|
require.NoError(t, cfg.LoadFile(confPath))
|
|
|
|
host, _ := cfg.Get("server.host")
|
|
assert.Equal(t, "json-host", host)
|
|
})
|
|
|
|
t.Run("ContentDetection", func(t *testing.T) {
|
|
cfg := New()
|
|
cfg.Register("server.host", "")
|
|
|
|
// Ambiguous extension
|
|
ambigPath := filepath.Join(tmpDir, "config.conf")
|
|
require.NoError(t, os.WriteFile(ambigPath, []byte(yamlConfig), 0644))
|
|
|
|
cfg.SetFileFormat("auto")
|
|
require.NoError(t, cfg.LoadFile(ambigPath))
|
|
|
|
host, _ := cfg.Get("server.host")
|
|
assert.Equal(t, "yaml-host", host)
|
|
})
|
|
}
|
|
|
|
// TestDynamicFormatSwitching tests runtime format changes
|
|
func TestDynamicFormatSwitching(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create configs in different formats with same structure
|
|
configs := map[string]string{
|
|
"toml": `value = "from-toml"`,
|
|
"json": `{"value": "from-json"}`,
|
|
"yaml": `value: from-yaml`,
|
|
}
|
|
|
|
cfg := New()
|
|
cfg.Register("value", "default")
|
|
|
|
for format, content := range configs {
|
|
t.Run(format, func(t *testing.T) {
|
|
filePath := filepath.Join(tmpDir, "config."+format)
|
|
require.NoError(t, os.WriteFile(filePath, []byte(content), 0644))
|
|
|
|
// Set format and load
|
|
require.NoError(t, cfg.SetFileFormat(format))
|
|
require.NoError(t, cfg.LoadFile(filePath))
|
|
|
|
val, _ := cfg.Get("value")
|
|
assert.Equal(t, "from-"+format, val)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestWatchFileFormatSwitch tests watching different file formats
|
|
func TestWatchFileFormatSwitch(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
tomlPath := filepath.Join(tmpDir, "config.toml")
|
|
jsonPath := filepath.Join(tmpDir, "config.json")
|
|
|
|
require.NoError(t, os.WriteFile(tomlPath, []byte(`value = "toml-1"`), 0644))
|
|
require.NoError(t, os.WriteFile(jsonPath, []byte(`{"value": "json-1"}`), 0644))
|
|
|
|
cfg := New()
|
|
cfg.Register("value", "default")
|
|
|
|
// Configure fast polling for test
|
|
opts := WatchOptions{
|
|
PollInterval: testPollInterval, // Fast polling for tests
|
|
Debounce: testDebounce, // Short debounce
|
|
MaxWatchers: 10,
|
|
}
|
|
|
|
// Start watching TOML
|
|
cfg.SetFileFormat("auto")
|
|
require.NoError(t, cfg.LoadFile(tomlPath))
|
|
cfg.AutoUpdateWithOptions(opts)
|
|
defer cfg.StopAutoUpdate()
|
|
|
|
// Wait for watcher to start
|
|
require.Eventually(t, func() bool {
|
|
return cfg.IsWatching()
|
|
}, 4*testDebounce, 2*SpinWaitInterval)
|
|
|
|
val, _ := cfg.Get("value")
|
|
assert.Equal(t, "toml-1", val)
|
|
|
|
// Switch to JSON with format hint
|
|
require.NoError(t, cfg.WatchFile(jsonPath, "json"))
|
|
|
|
// Wait for new watcher to start
|
|
require.Eventually(t, func() bool {
|
|
return cfg.IsWatching()
|
|
}, 4*testDebounce, 2*SpinWaitInterval)
|
|
|
|
// Get watch channel AFTER switching files
|
|
changes := cfg.Watch()
|
|
|
|
val, _ = cfg.Get("value")
|
|
assert.Equal(t, "json-1", val)
|
|
|
|
// Update JSON file
|
|
require.NoError(t, os.WriteFile(jsonPath, []byte(`{"value": "json-2"}`), 0644))
|
|
|
|
// Wait for change notification
|
|
select {
|
|
case path := <-changes:
|
|
assert.Equal(t, "value", path)
|
|
// Wait a bit for value to be updated
|
|
require.Eventually(t, func() bool {
|
|
val, _ := cfg.Get("value")
|
|
return val == "json-2"
|
|
}, testEventuallyTimeout, 2*SpinWaitInterval)
|
|
case <-time.After(testWatchTimeout):
|
|
t.Error("Timeout waiting for JSON file change")
|
|
}
|
|
|
|
// Update old TOML file - should NOT trigger notification
|
|
require.NoError(t, os.WriteFile(tomlPath, []byte(`value = "toml-2"`), 0644))
|
|
|
|
// Should not receive notification from old file
|
|
select {
|
|
case <-changes:
|
|
t.Error("Should not receive changes from old TOML file")
|
|
case <-time.After(testPollWindow):
|
|
// Expected - no change notification
|
|
}
|
|
}
|
|
|
|
// TestSecurityOptions tests security features
|
|
func TestSecurityOptions(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
t.Run("PathTraversal", func(t *testing.T) {
|
|
cfg := New()
|
|
cfg.SetSecurityOptions(SecurityOptions{
|
|
PreventPathTraversal: true,
|
|
})
|
|
|
|
// Test various malicious paths
|
|
maliciousPaths := []string{
|
|
"../../../etc/passwd",
|
|
"./../etc/passwd",
|
|
"config/../../../etc/passwd",
|
|
filepath.Join("..", "..", "etc", "passwd"),
|
|
}
|
|
|
|
for _, malPath := range maliciousPaths {
|
|
err := cfg.LoadFile(malPath)
|
|
assert.Error(t, err, "Should reject path: %s", malPath)
|
|
assert.Contains(t, err.Error(), "path traversal")
|
|
}
|
|
|
|
// Valid paths should work
|
|
validPath := filepath.Join(tmpDir, "config.toml")
|
|
os.WriteFile(validPath, []byte(`test = "value"`), 0644)
|
|
cfg.Register("test", "")
|
|
|
|
err := cfg.LoadFile(validPath)
|
|
assert.NoError(t, err, "Should accept valid absolute path")
|
|
})
|
|
|
|
t.Run("FileSizeLimit", func(t *testing.T) {
|
|
cfg := New()
|
|
cfg.SetSecurityOptions(SecurityOptions{
|
|
MaxFileSize: 100, // 100 bytes limit
|
|
})
|
|
|
|
// Create large file
|
|
largePath := filepath.Join(tmpDir, "large.toml")
|
|
largeContent := make([]byte, 1024)
|
|
for i := range largeContent {
|
|
largeContent[i] = 'a'
|
|
}
|
|
require.NoError(t, os.WriteFile(largePath, largeContent, 0644))
|
|
|
|
err := cfg.LoadFile(largePath)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "exceeds maximum size")
|
|
})
|
|
|
|
t.Run("FileOwnership", func(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Skipping ownership test on Windows")
|
|
}
|
|
|
|
cfg := New()
|
|
cfg.SetSecurityOptions(SecurityOptions{
|
|
EnforceFileOwnership: true,
|
|
})
|
|
|
|
// Create file owned by current user (should succeed)
|
|
ownedPath := filepath.Join(tmpDir, "owned.toml")
|
|
require.NoError(t, os.WriteFile(ownedPath, []byte(`test = "value"`), 0644))
|
|
|
|
cfg.Register("test", "")
|
|
err := cfg.LoadFile(ownedPath)
|
|
assert.NoError(t, err)
|
|
})
|
|
}
|
|
|
|
// waitForWatchingState waits for watcher state, preventing race conditions of goroutine start and test check
|
|
func waitForWatchingState(t *testing.T, cfg *Config, expected bool, msgAndArgs ...any) {
|
|
t.Helper()
|
|
require.Eventually(t, func() bool {
|
|
return cfg.IsWatching() == expected
|
|
}, testEventuallyTimeout, 2*SpinWaitInterval, msgAndArgs...)
|
|
}
|
|
|
|
// TestBuilderWithFormat tests Builder integration
|
|
func TestBuilderWithFormat(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
jsonPath := filepath.Join(tmpDir, "config.json")
|
|
|
|
jsonConfig := `{
|
|
"server": {
|
|
"host": "builder-host",
|
|
"port": 8080
|
|
}
|
|
}`
|
|
require.NoError(t, os.WriteFile(jsonPath, []byte(jsonConfig), 0644))
|
|
|
|
type Config struct {
|
|
Server struct {
|
|
Host string `json:"host" toml:"host"`
|
|
Port int `json:"port" toml:"port"`
|
|
} `json:"server" toml:"server"`
|
|
}
|
|
|
|
defaults := &Config{}
|
|
defaults.Server.Host = "default-host"
|
|
defaults.Server.Port = 3000
|
|
|
|
cfg, err := NewBuilder().
|
|
WithDefaults(defaults).
|
|
WithFile(jsonPath).
|
|
WithFileFormat("json").
|
|
WithTagName("toml"). // Use toml tags for registration
|
|
WithSecurityOptions(SecurityOptions{
|
|
PreventPathTraversal: true,
|
|
MaxFileSize: 1024 * 1024, // 1MB
|
|
}).
|
|
Build()
|
|
|
|
require.NoError(t, err)
|
|
|
|
// Check the value was loaded
|
|
host, exists := cfg.Get("server.host")
|
|
assert.True(t, exists, "server.host should exist")
|
|
assert.Equal(t, "builder-host", host)
|
|
|
|
port, exists := cfg.Get("server.port")
|
|
assert.True(t, exists, "server.port should exist")
|
|
// Handle json.Number or converted int
|
|
switch v := port.(type) {
|
|
case json.Number:
|
|
p, _ := v.Int64()
|
|
assert.Equal(t, int64(8080), p)
|
|
case int64:
|
|
assert.Equal(t, int64(8080), v)
|
|
case float64:
|
|
assert.Equal(t, float64(8080), v)
|
|
default:
|
|
t.Errorf("Unexpected type for port: %T", port)
|
|
}
|
|
}
|
|
|
|
// BenchmarkFormatParsing benchmarks different format parsing speeds
|
|
func BenchmarkFormatParsing(b *testing.B) {
|
|
tmpDir := b.TempDir()
|
|
|
|
// Create test data
|
|
configs := map[string]string{
|
|
"toml": `
|
|
[server]
|
|
host = "localhost"
|
|
port = 8080
|
|
[database]
|
|
url = "postgres://localhost/db"
|
|
[cache]
|
|
ttl = 300
|
|
`,
|
|
"json": `{
|
|
"server": {"host": "localhost", "port": 8080},
|
|
"database": {"url": "postgres://localhost/db"},
|
|
"cache": {"ttl": 300}
|
|
}`,
|
|
"yaml": `
|
|
server:
|
|
host: localhost
|
|
port: 8080
|
|
database:
|
|
url: postgres://localhost/db
|
|
cache:
|
|
ttl: 300
|
|
`,
|
|
}
|
|
|
|
for format, content := range configs {
|
|
b.Run(format, func(b *testing.B) {
|
|
path := filepath.Join(tmpDir, "bench."+format)
|
|
os.WriteFile(path, []byte(content), 0644)
|
|
|
|
cfg := New()
|
|
cfg.Register("server.host", "")
|
|
cfg.Register("server.port", 0)
|
|
cfg.Register("database.url", "")
|
|
cfg.Register("cache.ttl", 0)
|
|
cfg.SetFileFormat(format)
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
cfg.LoadFile(path)
|
|
}
|
|
})
|
|
}
|
|
}
|