- Fix Go version to 1.24.4 in go.mod - Add version management with version.go - Add --version flag to CLI tool - Remove deprecated LoadFromFile and LoadFromReader methods - Update tests to use new API - Create TODO.md for future improvements - Update README with Go version requirement Co-Authored-By: Claude <noreply@anthropic.com>
408 lines
11 KiB
Go
408 lines
11 KiB
Go
package smartconfig
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
func TestEnvResolver(t *testing.T) {
|
|
_ = os.Setenv("TEST_VAR", "test-value")
|
|
defer func() { _ = os.Unsetenv("TEST_VAR") }()
|
|
|
|
resolver := &EnvResolver{}
|
|
result, err := resolver.Resolve("TEST_VAR")
|
|
if err != nil {
|
|
t.Fatalf("Failed to resolve env var: %v", err)
|
|
}
|
|
if result != "test-value" {
|
|
t.Errorf("Expected 'test-value', got '%s'", result)
|
|
}
|
|
}
|
|
|
|
func TestExecResolver(t *testing.T) {
|
|
resolver := &ExecResolver{}
|
|
result, err := resolver.Resolve("echo hello")
|
|
if err != nil {
|
|
t.Fatalf("Failed to execute command: %v", err)
|
|
}
|
|
if result != "hello" {
|
|
t.Errorf("Expected 'hello', got '%s'", result)
|
|
}
|
|
}
|
|
|
|
func TestFileResolver(t *testing.T) {
|
|
resolver := &FileResolver{}
|
|
result, err := resolver.Resolve("./test/machine-id")
|
|
if err != nil {
|
|
t.Fatalf("Failed to read file: %v", err)
|
|
}
|
|
if result != "test-machine-123456789" {
|
|
t.Errorf("Expected 'test-machine-123456789', got '%s'", result)
|
|
}
|
|
}
|
|
|
|
func TestJSONResolver(t *testing.T) {
|
|
resolver := &JSONResolver{}
|
|
|
|
// Test simple path
|
|
result, err := resolver.Resolve("./test/hosts.json:.")
|
|
if err != nil {
|
|
t.Fatalf("Failed to resolve JSON: %v", err)
|
|
}
|
|
if !strings.Contains(result, "tls_sni_host") {
|
|
t.Errorf("Expected JSON output to contain 'tls_sni_host', got '%s'", result)
|
|
}
|
|
}
|
|
|
|
func TestYAMLResolver(t *testing.T) {
|
|
resolver := &YAMLResolver{}
|
|
|
|
// Test simple path
|
|
result, err := resolver.Resolve("./test/data.yaml:.")
|
|
if err != nil {
|
|
t.Fatalf("Failed to resolve YAML: %v", err)
|
|
}
|
|
if !strings.Contains(result, "database") {
|
|
t.Errorf("Expected YAML output to contain 'database', got '%s'", result)
|
|
}
|
|
}
|
|
|
|
func TestInterpolateBasic(t *testing.T) {
|
|
_ = os.Setenv("BASIC", "value")
|
|
defer func() { _ = os.Unsetenv("BASIC") }()
|
|
|
|
content := "test: ${ENV:BASIC}"
|
|
config, err := NewFromReader(strings.NewReader(content))
|
|
if err != nil {
|
|
t.Fatalf("Failed to load config: %v", err)
|
|
}
|
|
|
|
value, err := config.GetString("test")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get test value: %v", err)
|
|
}
|
|
if value != "value" {
|
|
t.Errorf("Expected 'value', got '%s'", value)
|
|
}
|
|
}
|
|
|
|
func TestInterpolateMethod(t *testing.T) {
|
|
config := New()
|
|
_ = os.Setenv("FOO", "bar")
|
|
defer func() { _ = os.Unsetenv("FOO") }()
|
|
|
|
// Test direct interpolation
|
|
result, _ := config.interpolate("${ENV:FOO}", 0)
|
|
if result != "\"bar\"" {
|
|
t.Errorf("Expected '\"bar\"', got '%s'", result)
|
|
}
|
|
}
|
|
|
|
func TestInterpolateSimple(t *testing.T) {
|
|
config := New()
|
|
_ = os.Setenv("TEST", "value")
|
|
defer func() { _ = os.Unsetenv("TEST") }()
|
|
|
|
// Verify regex finds the match
|
|
re := regexp.MustCompile(`\$\{([^:]+?):(.*?)\}`)
|
|
matches := re.FindAllStringSubmatch("${ENV:TEST}", -1)
|
|
if len(matches) == 0 {
|
|
t.Fatal("Regex didn't find any matches")
|
|
}
|
|
|
|
// Test simple interpolation
|
|
result, _ := config.interpolate("${ENV:TEST}", 0)
|
|
if result != "\"value\"" {
|
|
t.Errorf("Simple interpolation failed: expected '\"value\"', got '%s'", result)
|
|
}
|
|
}
|
|
|
|
func TestInterpolateStep(t *testing.T) {
|
|
config := New()
|
|
|
|
// Set env vars
|
|
_ = os.Setenv("BAR", "value1")
|
|
_ = os.Setenv("FOO_value1", "success")
|
|
defer func() {
|
|
_ = os.Unsetenv("BAR")
|
|
_ = os.Unsetenv("FOO_value1")
|
|
}()
|
|
|
|
// Step 1: Inner interpolation should resolve first
|
|
step1 := "${ENV:BAR}"
|
|
result1, _ := config.interpolate(step1, 0)
|
|
if result1 != "\"value1\"" {
|
|
t.Errorf("Step 1 failed: expected '\"value1\"', got '%s'", result1)
|
|
}
|
|
|
|
// Step 2: With the result, build the outer
|
|
step2 := "${ENV:FOO_value1}"
|
|
result2, _ := config.interpolate(step2, 0)
|
|
if result2 != "\"success\"" {
|
|
t.Errorf("Step 2 failed: expected '\"success\"', got '%s'", result2)
|
|
}
|
|
|
|
// Step 3: Full nested
|
|
full := "${ENV:FOO_${ENV:BAR}}"
|
|
result3, _ := config.interpolate(full, 0)
|
|
if result3 != "\"success\"" {
|
|
t.Errorf("Full nested failed: expected '\"success\"', got '%s'", result3)
|
|
}
|
|
}
|
|
|
|
func TestRegexPattern(t *testing.T) {
|
|
re := regexp.MustCompile(`\$\{([^:]+?):(.*?)\}`)
|
|
|
|
// Test simple case
|
|
matches := re.FindStringSubmatch("${ENV:FOO}")
|
|
if len(matches) != 3 || matches[1] != "ENV" || matches[2] != "FOO" {
|
|
t.Errorf("Simple pattern failed: %v", matches)
|
|
}
|
|
|
|
// Test nested case - this will fail with current regex
|
|
input := "${ENV:FOO_${ENV:BAR}}"
|
|
matches = re.FindStringSubmatch(input)
|
|
if len(matches) == 3 {
|
|
t.Logf("Nested pattern matches: resolver=%s, value=%s", matches[1], matches[2])
|
|
// This will show that value is "FOO_${ENV:BAR" which is wrong
|
|
}
|
|
}
|
|
|
|
func TestInterpolateNested(t *testing.T) {
|
|
config := New()
|
|
_ = os.Setenv("INNER", "BAR")
|
|
_ = os.Setenv("FOO_BAR", "success")
|
|
defer func() {
|
|
_ = os.Unsetenv("INNER")
|
|
_ = os.Unsetenv("FOO_BAR")
|
|
}()
|
|
|
|
// Test nested interpolation directly
|
|
result, _ := config.interpolate("${ENV:FOO_${ENV:INNER}}", 0)
|
|
if result != "\"success\"" {
|
|
t.Errorf("Expected '\"success\"', got '%s'", result)
|
|
}
|
|
}
|
|
|
|
func TestSimpleNestedInterpolation(t *testing.T) {
|
|
_ = os.Setenv("SUFFIX", "BAR")
|
|
_ = os.Setenv("FOO_BAR", "success")
|
|
defer func() {
|
|
_ = os.Unsetenv("SUFFIX")
|
|
_ = os.Unsetenv("FOO_BAR")
|
|
}()
|
|
|
|
content := "test: ${ENV:FOO_${ENV:SUFFIX}}"
|
|
config, err := NewFromReader(strings.NewReader(content))
|
|
if err != nil {
|
|
t.Fatalf("Failed to load config: %v", err)
|
|
}
|
|
|
|
value, err := config.GetString("test")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get test value: %v", err)
|
|
}
|
|
if value != "success" {
|
|
t.Errorf("Expected 'success', got '%s'", value)
|
|
}
|
|
}
|
|
|
|
func TestConfigInterpolation(t *testing.T) {
|
|
// Set up environment
|
|
_ = os.Setenv("TEST_APP_NAME", "myapp")
|
|
_ = os.Setenv("TEST_PORT", "8080")
|
|
_ = os.Setenv("TEST_ENV_SUFFIX", "VALUE")
|
|
_ = os.Setenv("NESTED_VALUE", "nested-result")
|
|
defer func() {
|
|
_ = os.Unsetenv("TEST_APP_NAME")
|
|
_ = os.Unsetenv("TEST_PORT")
|
|
_ = os.Unsetenv("TEST_ENV_SUFFIX")
|
|
_ = os.Unsetenv("NESTED_VALUE")
|
|
}()
|
|
|
|
config, err := NewFromConfigPath("./test/config.yaml")
|
|
if err != nil {
|
|
t.Fatalf("Failed to load config: %v", err)
|
|
}
|
|
|
|
// Test basic interpolation
|
|
name, err := config.GetString("name")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get name: %v", err)
|
|
}
|
|
if name != "myapp" {
|
|
t.Errorf("Expected name 'myapp', got '%s'", name)
|
|
}
|
|
|
|
// Test exec interpolation
|
|
if serverData, ok := config.data["server"].(map[string]interface{}); ok {
|
|
if hostname, ok := serverData["hostname"].(string); ok {
|
|
if hostname == "" {
|
|
t.Errorf("Expected hostname to be non-empty")
|
|
}
|
|
} else {
|
|
t.Errorf("Expected server.hostname to be a string")
|
|
}
|
|
} else {
|
|
t.Errorf("Expected server to be a map")
|
|
}
|
|
|
|
// Test file interpolation
|
|
if machineData, ok := config.data["machine"].(map[string]interface{}); ok {
|
|
if id, ok := machineData["id"].(string); ok {
|
|
if id != "test-machine-123456789" {
|
|
t.Errorf("Expected machine.id 'test-machine-123456789', got '%s'", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Test nested interpolation
|
|
if nestedData, ok := config.data["nested"].(map[string]interface{}); ok {
|
|
if value, ok := nestedData["value"].(string); ok {
|
|
if value != "nested-result" {
|
|
t.Errorf("Expected nested.value 'nested-result', got '%s'", value)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Test environment injection
|
|
injectedVar := os.Getenv("INJECTED_VAR")
|
|
if injectedVar != "injected-value" {
|
|
t.Errorf("Expected INJECTED_VAR to be 'injected-value', got '%s'", injectedVar)
|
|
}
|
|
|
|
computedVar := os.Getenv("COMPUTED_VAR")
|
|
if computedVar != "computed" {
|
|
t.Errorf("Expected COMPUTED_VAR to be 'computed', got '%s'", computedVar)
|
|
}
|
|
}
|
|
|
|
func TestRecursionLimit(t *testing.T) {
|
|
// Create a deeply nested interpolation that hits the recursion limit
|
|
// This has 4 levels, but our max is 3
|
|
content := "value: ${ENV:LEVEL1_${ENV:LEVEL2_${ENV:LEVEL3_${ENV:LEVEL4}}}}"
|
|
|
|
// Set up environment variables for a complete chain
|
|
_ = os.Setenv("LEVEL4", "END")
|
|
_ = os.Setenv("LEVEL3_END", "3")
|
|
_ = os.Setenv("LEVEL2_3", "2")
|
|
_ = os.Setenv("LEVEL1_2", "final")
|
|
// Also set the partial resolution that would be needed at depth 3
|
|
_ = os.Setenv("LEVEL3_${ENV:LEVEL4}", "depth3value")
|
|
_ = os.Setenv("LEVEL2_depth3value", "depth2value")
|
|
_ = os.Setenv("LEVEL1_depth2value", "final_with_limit")
|
|
defer func() {
|
|
_ = os.Unsetenv("LEVEL4")
|
|
_ = os.Unsetenv("LEVEL3_END")
|
|
_ = os.Unsetenv("LEVEL2_3")
|
|
_ = os.Unsetenv("LEVEL1_2")
|
|
_ = os.Unsetenv("LEVEL3_${ENV:LEVEL4}")
|
|
_ = os.Unsetenv("LEVEL2_depth3value")
|
|
_ = os.Unsetenv("LEVEL1_depth2value")
|
|
}()
|
|
|
|
config, err := NewFromReader(strings.NewReader(content))
|
|
if err != nil {
|
|
t.Fatalf("Failed to load config: %v", err)
|
|
}
|
|
|
|
// With the new implementation, nested interpolations are handled before type conversion
|
|
// The recursion still works up to the limit, so we should get "final"
|
|
value, _ := config.GetString("value")
|
|
if value != "final" {
|
|
t.Errorf("Expected value to be 'final', got '%s'", value)
|
|
}
|
|
}
|
|
|
|
func TestInvalidResolverFormat(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
resolver Resolver
|
|
input string
|
|
}{
|
|
{"JSON missing path", &JSONResolver{}, "file.json"},
|
|
{"YAML missing path", &YAMLResolver{}, "file.yaml"},
|
|
{"Vault missing key", &VaultResolver{}, "secret/data/myapp"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := tt.resolver.Resolve(tt.input)
|
|
if err == nil {
|
|
t.Errorf("Expected error for invalid format")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUnknownResolver(t *testing.T) {
|
|
content := "value: ${UNKNOWN:test}"
|
|
|
|
// Unknown resolver should cause an error
|
|
_, err := NewFromReader(strings.NewReader(content))
|
|
if err == nil {
|
|
t.Fatal("Expected error for unknown resolver, but got none")
|
|
}
|
|
if !strings.Contains(err.Error(), "unknown resolver: UNKNOWN") {
|
|
t.Errorf("Expected error to mention unknown resolver UNKNOWN, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestYAMLParsing(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
yaml string
|
|
expected interface{}
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "interpolation with special chars",
|
|
yaml: `keyname: ${EXEC:"blahblah < /one/two/three%"}`,
|
|
expected: `${EXEC:"blahblah < /one/two/three%"}`,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "unquoted interpolation",
|
|
yaml: `keyname: ${EXEC:echo hello}`,
|
|
expected: `${EXEC:echo hello}`,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "numeric value",
|
|
yaml: `port: 8080`,
|
|
expected: 8080,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "boolean value",
|
|
yaml: `enabled: true`,
|
|
expected: true,
|
|
wantErr: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var data map[string]interface{}
|
|
err := yaml.Unmarshal([]byte(tc.yaml), &data)
|
|
if (err != nil) != tc.wantErr {
|
|
t.Errorf("Unmarshal error = %v, wantErr %v", err, tc.wantErr)
|
|
return
|
|
}
|
|
if err == nil {
|
|
for key, value := range data {
|
|
t.Logf("%s: %v (type: %T)", key, value, value)
|
|
if fmt.Sprintf("%v", value) != fmt.Sprintf("%v", tc.expected) {
|
|
t.Errorf("Expected %v, got %v", tc.expected, value)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|