e6.0.0 Added file format change and security option support.
This commit is contained in:
418
dynamic_test.go
Normal file
418
dynamic_test.go
Normal file
@ -0,0 +1,418 @@
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user