smartconfig/typed_interpolation_test.go
sneak e7fa389634 Add safety check for numeric type conversions
Ensure that numeric conversions are lossless by verifying the converted
value matches the original string when converted back. This prevents:
- Loss of leading zeros (0123 stays as string)
- Loss of trailing zeros in floats (1.50 stays as string)
- Loss of plus signs (+123 stays as string)
- Changes in notation (1e10 stays as string)

Only convert to numeric types when the string representation would be
preserved exactly. This maintains data integrity while still providing
convenient type conversions for clean numeric values.

Added comprehensive tests to verify the safety check behavior.
2025-07-21 16:43:39 +02:00

258 lines
6.2 KiB
Go

package smartconfig
import (
"fmt"
"os"
"strings"
"testing"
)
func TestTypedInterpolation(t *testing.T) {
testCases := []struct {
name string
yaml string
env map[string]string
checks []struct {
key string
expectedVal interface{}
expectedType string
}
}{
{
name: "unquoted number interpolation",
yaml: `port: ${ENV:PORT}`,
env: map[string]string{"PORT": "8080"},
checks: []struct {
key string
expectedVal interface{}
expectedType string
}{
{key: "port", expectedVal: 8080, expectedType: "int"},
},
},
{
name: "force string with prefix",
yaml: `port: "port-${ENV:PORT}"`,
env: map[string]string{"PORT": "8080"},
checks: []struct {
key string
expectedVal interface{}
expectedType string
}{
{key: "port", expectedVal: "port-8080", expectedType: "string"},
},
},
{
name: "boolean interpolation",
yaml: `enabled: ${ENV:ENABLED}`,
env: map[string]string{"ENABLED": "true"},
checks: []struct {
key string
expectedVal interface{}
expectedType string
}{
{key: "enabled", expectedVal: true, expectedType: "bool"},
},
},
{
name: "float interpolation",
yaml: `timeout: ${ENV:TIMEOUT}`,
env: map[string]string{"TIMEOUT": "30.5"},
checks: []struct {
key string
expectedVal interface{}
expectedType string
}{
{key: "timeout", expectedVal: 30.5, expectedType: "float64"},
},
},
{
name: "mixed content stays string",
yaml: `message: "Hello ${ENV:NAME}!"`,
env: map[string]string{"NAME": "World"},
checks: []struct {
key string
expectedVal interface{}
expectedType string
}{
{key: "message", expectedVal: "Hello World!", expectedType: "string"},
},
},
{
name: "nested interpolation with type",
yaml: `value: ${ENV:PREFIX_${ENV:SUFFIX}}`,
env: map[string]string{"SUFFIX": "VAL", "PREFIX_VAL": "42"},
checks: []struct {
key string
expectedVal interface{}
expectedType string
}{
{key: "value", expectedVal: 42, expectedType: "int"},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Set environment variables
for k, v := range tc.env {
_ = os.Setenv(k, v)
defer func(key string) { _ = os.Unsetenv(key) }(k)
}
// Load config
config, err := NewFromReader(strings.NewReader(tc.yaml))
if err != nil {
t.Fatalf("Failed to load config: %v", err)
}
// Check values and types
for _, check := range tc.checks {
val, exists := config.Get(check.key)
if !exists {
t.Errorf("Key %s not found", check.key)
continue
}
// Check type
actualType := ""
switch val.(type) {
case string:
actualType = "string"
case int:
actualType = "int"
case float64:
actualType = "float64"
case bool:
actualType = "bool"
default:
actualType = "unknown"
}
if actualType != check.expectedType {
t.Errorf("Key %s: expected type %s, got %s (value: %v)",
check.key, check.expectedType, actualType, val)
}
// Check value
if val != check.expectedVal {
t.Errorf("Key %s: expected value %v, got %v",
check.key, check.expectedVal, val)
}
}
})
}
}
func TestNumericConversionSafety(t *testing.T) {
tests := []struct {
name string
input string
expected interface{}
desc string
}{
{
name: "integer with leading zeros stays string",
input: "0123",
expected: "0123",
desc: "Leading zeros should prevent integer conversion",
},
{
name: "float with trailing zeros",
input: "1.50",
expected: "1.50",
desc: "Trailing zeros should prevent float conversion",
},
{
name: "scientific notation stays string",
input: "1e10",
expected: "1e10",
desc: "Scientific notation should stay as string",
},
{
name: "integer within range converts",
input: "12345",
expected: 12345,
desc: "Normal integers should convert",
},
{
name: "float with no trailing zeros converts",
input: "123.45",
expected: 123.45,
desc: "Normal floats should convert",
},
{
name: "negative integer converts",
input: "-123",
expected: -123,
desc: "Negative integers should convert",
},
{
name: "negative float converts",
input: "-123.45",
expected: -123.45,
desc: "Negative floats should convert",
},
{
name: "very large number stays string",
input: "999999999999999999999999999999",
expected: "999999999999999999999999999999",
desc: "Numbers beyond int range should stay as strings",
},
{
name: "float that looks like int converts to int",
input: "123.0",
expected: "123.0",
desc: "Float notation should be preserved even for whole numbers",
},
{
name: "plus sign prevents conversion",
input: "+123",
expected: "+123",
desc: "Plus sign should prevent conversion",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Set up environment variable
varName := "TEST_NUMERIC_" + strings.ToUpper(strings.ReplaceAll(tt.name, " ", "_"))
_ = os.Setenv(varName, tt.input)
defer func() { _ = os.Unsetenv(varName) }()
yaml := fmt.Sprintf("value: ${ENV:%s}", varName)
config, err := NewFromReader(strings.NewReader(yaml))
if err != nil {
t.Fatalf("Failed to parse YAML: %v", err)
}
val, exists := config.Get("value")
if !exists {
t.Fatal("Expected value to exist")
}
// Check type and value
switch expected := tt.expected.(type) {
case int:
if i, ok := val.(int); !ok {
t.Errorf("%s: Expected int, got %T: %v", tt.desc, val, val)
} else if i != expected {
t.Errorf("%s: Expected %d, got %d", tt.desc, expected, i)
}
case float64:
if f, ok := val.(float64); !ok {
t.Errorf("%s: Expected float64, got %T: %v", tt.desc, val, val)
} else if f != expected {
t.Errorf("%s: Expected %f, got %f", tt.desc, expected, f)
}
case string:
if s, ok := val.(string); !ok {
t.Errorf("%s: Expected string, got %T: %v", tt.desc, val, val)
} else if s != expected {
t.Errorf("%s: Expected %q, got %q", tt.desc, expected, s)
}
}
})
}
}