This change enhances the API to support gjson path syntax for accessing nested configuration values. Users can now use paths like 'server.ssl.enabled' or 'database.replicas.0.host' to directly access nested values without manual navigation. Key changes: - All typed getters now support gjson path syntax - Backward compatibility maintained for top-level keys - Proper error handling for null values and non-existent paths - Special float values (Infinity, NaN) handled correctly - Comprehensive test coverage for edge cases This makes the API much more intuitive and reduces boilerplate code when working with nested configuration structures.
394 lines
10 KiB
Go
394 lines
10 KiB
Go
package smartconfig
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestGjsonPathEdgeCases(t *testing.T) {
|
|
yamlContent := `
|
|
# Edge case test data
|
|
empty_string: ""
|
|
null_value: null
|
|
zero: 0
|
|
false_bool: false
|
|
empty_map: {}
|
|
empty_array: []
|
|
|
|
special_keys:
|
|
"key.with.dots": "dotted key value"
|
|
"key with spaces": "spaced key value"
|
|
"key[with]brackets": "bracketed key value"
|
|
"key{with}braces": "braced key value"
|
|
"123": "numeric key"
|
|
"": "empty key"
|
|
"key\"with\"quotes": "quoted key"
|
|
"key'with'quotes": "single quoted key"
|
|
"key\nwith\nnewlines": "newline key"
|
|
"key\twith\ttabs": "tab key"
|
|
|
|
deeply:
|
|
nested:
|
|
structure:
|
|
with:
|
|
many:
|
|
levels:
|
|
value: "deep value"
|
|
|
|
arrays:
|
|
nested:
|
|
- - - value: "triple nested array"
|
|
mixed:
|
|
- name: "first"
|
|
items:
|
|
- id: 1
|
|
- id: 2
|
|
- name: "second"
|
|
items:
|
|
- id: 3
|
|
- id: 4
|
|
|
|
unicode:
|
|
"emoji🎉key": "emoji value"
|
|
"中文键": "chinese value"
|
|
"مفتاح": "arabic value"
|
|
"ключ": "russian value"
|
|
|
|
types:
|
|
very_large_number: 9223372036854775807 # max int64
|
|
very_small_number: -9223372036854775808 # min int64
|
|
float_edge: 1.7976931348623157e+308 # near max float64
|
|
tiny_float: 2.2250738585072014e-308 # near min positive float64
|
|
|
|
special_values:
|
|
inf: .inf
|
|
neg_inf: -.inf
|
|
nan: .nan
|
|
inf_string: "Infinity"
|
|
neg_inf_string: "-Infinity"
|
|
nan_string: "NaN"
|
|
`
|
|
|
|
config, err := NewFromReader(strings.NewReader(yamlContent))
|
|
if err != nil {
|
|
t.Fatalf("Failed to load config: %v", err)
|
|
}
|
|
|
|
t.Run("Empty and null values", func(t *testing.T) {
|
|
// Empty string
|
|
val, err := config.GetString("empty_string")
|
|
if err != nil || val != "" {
|
|
t.Errorf("GetString(empty_string) = %q, %v; want empty string, nil", val, err)
|
|
}
|
|
|
|
// Zero value
|
|
zero, err := config.GetInt("zero")
|
|
if err != nil || zero != 0 {
|
|
t.Errorf("GetInt(zero) = %d, %v; want 0, nil", zero, err)
|
|
}
|
|
|
|
// False boolean
|
|
falseBool, err := config.GetBool("false_bool")
|
|
if err != nil || falseBool != false {
|
|
t.Errorf("GetBool(false_bool) = %v, %v; want false, nil", falseBool, err)
|
|
}
|
|
|
|
// Null value should fail for typed getters
|
|
_, err = config.GetString("null_value")
|
|
if err == nil {
|
|
t.Error("GetString(null_value) should return error for null")
|
|
}
|
|
})
|
|
|
|
t.Run("Special character keys", func(t *testing.T) {
|
|
// Keys with dots should be accessible with escaped syntax
|
|
// Note: gjson doesn't support accessing keys with dots directly
|
|
// This is a known limitation
|
|
|
|
// Empty map
|
|
emptyMap, exists := config.Get("empty_map")
|
|
if !exists {
|
|
t.Error("empty_map should exist")
|
|
}
|
|
if m, ok := emptyMap.(map[string]interface{}); !ok || len(m) != 0 {
|
|
t.Errorf("empty_map should be an empty map, got %v", emptyMap)
|
|
}
|
|
|
|
// Empty array
|
|
emptyArray, exists := config.Get("empty_array")
|
|
if !exists {
|
|
t.Error("empty_array should exist")
|
|
}
|
|
if a, ok := emptyArray.([]interface{}); !ok || len(a) != 0 {
|
|
t.Errorf("empty_array should be an empty array, got %v", emptyArray)
|
|
}
|
|
})
|
|
|
|
t.Run("Very deep nesting", func(t *testing.T) {
|
|
deepValue, err := config.GetString("deeply.nested.structure.with.many.levels.value")
|
|
if err != nil || deepValue != "deep value" {
|
|
t.Errorf("Deep nested access failed: %v, %v", deepValue, err)
|
|
}
|
|
})
|
|
|
|
t.Run("Complex array access", func(t *testing.T) {
|
|
// Triple nested array
|
|
val, err := config.GetString("arrays.nested.0.0.0.value")
|
|
if err != nil || val != "triple nested array" {
|
|
t.Errorf("Triple nested array access = %q, %v; want 'triple nested array', nil", val, err)
|
|
}
|
|
|
|
// Mixed structure navigation
|
|
id1, err := config.GetInt("arrays.mixed.0.items.0.id")
|
|
if err != nil || id1 != 1 {
|
|
t.Errorf("Mixed array access = %d, %v; want 1, nil", id1, err)
|
|
}
|
|
|
|
id4, err := config.GetInt("arrays.mixed.1.items.1.id")
|
|
if err != nil || id4 != 4 {
|
|
t.Errorf("Mixed array access = %d, %v; want 4, nil", id4, err)
|
|
}
|
|
})
|
|
|
|
t.Run("Unicode keys", func(t *testing.T) {
|
|
// Unicode keys should work normally
|
|
chineseVal, exists := config.Get("unicode.中文键")
|
|
if !exists || chineseVal != "chinese value" {
|
|
t.Errorf("Unicode key access failed: %v", chineseVal)
|
|
}
|
|
})
|
|
|
|
t.Run("Type edge cases", func(t *testing.T) {
|
|
// Very large numbers
|
|
largeNum, err := config.GetInt("types.very_large_number")
|
|
if err != nil {
|
|
t.Errorf("GetInt(very_large_number) failed: %v", err)
|
|
}
|
|
if largeNum != 9223372036854775807 {
|
|
t.Errorf("Large number = %d, want 9223372036854775807", largeNum)
|
|
}
|
|
|
|
// Very small numbers
|
|
smallNum, err := config.GetInt("types.very_small_number")
|
|
if err != nil {
|
|
t.Errorf("GetInt(very_small_number) failed: %v", err)
|
|
}
|
|
if smallNum != -9223372036854775808 {
|
|
t.Errorf("Small number = %d, want -9223372036854775808", smallNum)
|
|
}
|
|
})
|
|
|
|
t.Run("Special float values", func(t *testing.T) {
|
|
// These special float values should be converted to strings
|
|
inf, err := config.GetString("special_values.inf")
|
|
if err != nil || inf != "Infinity" {
|
|
t.Errorf("GetString(inf) = %q, %v; want 'Infinity', nil", inf, err)
|
|
}
|
|
|
|
negInf, err := config.GetString("special_values.neg_inf")
|
|
if err != nil || negInf != "-Infinity" {
|
|
t.Errorf("GetString(neg_inf) = %q, %v; want '-Infinity', nil", negInf, err)
|
|
}
|
|
|
|
nan, err := config.GetString("special_values.nan")
|
|
if err != nil || nan != "NaN" {
|
|
t.Errorf("GetString(nan) = %q, %v; want 'NaN', nil", nan, err)
|
|
}
|
|
})
|
|
|
|
t.Run("Invalid paths", func(t *testing.T) {
|
|
// Various invalid path formats
|
|
invalidPaths := []string{
|
|
"...", // Only dots
|
|
"key.", // Trailing dot
|
|
".key", // Leading dot
|
|
"key..value", // Double dots
|
|
"key..", // Double trailing dots
|
|
"key[", // Unclosed bracket
|
|
"key]", // Unmatched bracket
|
|
// "key.[0]", // Actually valid gjson syntax
|
|
"arrays.mixed.-1.name", // Negative array index
|
|
"arrays.mixed.999.name", // Out of bounds array index
|
|
"", // Empty path
|
|
"nonexistent", // Simple non-existent key
|
|
"nonexistent.nested.key", // Nested non-existent
|
|
}
|
|
|
|
for _, path := range invalidPaths {
|
|
t.Run(path, func(t *testing.T) {
|
|
_, err := config.GetString(path)
|
|
if err == nil {
|
|
t.Errorf("GetString(%q) should return error", path)
|
|
}
|
|
|
|
_, exists := config.Get(path)
|
|
if exists {
|
|
t.Errorf("Get(%q) should return false for exists", path)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("Type conversion edge cases", func(t *testing.T) {
|
|
// Try to get string as various types
|
|
strConfig := `
|
|
string_number: "123"
|
|
string_bool: "true"
|
|
string_float: "45.67"
|
|
string_bytes: "1GB"
|
|
not_a_number: "abc"
|
|
not_a_bool: "yes"
|
|
partial_number: "123abc"
|
|
`
|
|
c, _ := NewFromReader(strings.NewReader(strConfig))
|
|
|
|
// String to int
|
|
num, err := c.GetInt("string_number")
|
|
if err != nil || num != 123 {
|
|
t.Errorf("GetInt(string_number) = %d, %v; want 123, nil", num, err)
|
|
}
|
|
|
|
// String to bool
|
|
b, err := c.GetBool("string_bool")
|
|
if err != nil || b != true {
|
|
t.Errorf("GetBool(string_bool) = %v, %v; want true, nil", b, err)
|
|
}
|
|
|
|
// Invalid conversions
|
|
_, err = c.GetInt("not_a_number")
|
|
if err == nil {
|
|
t.Error("GetInt(not_a_number) should fail")
|
|
}
|
|
|
|
_, err = c.GetBool("not_a_bool")
|
|
if err == nil {
|
|
t.Error("GetBool(not_a_bool) should fail")
|
|
}
|
|
|
|
_, err = c.GetInt("partial_number")
|
|
if err == nil {
|
|
t.Error("GetInt(partial_number) should fail")
|
|
}
|
|
})
|
|
|
|
t.Run("Gjson special syntax", func(t *testing.T) {
|
|
// Test that gjson special syntax doesn't cause issues
|
|
dangerousPaths := []string{
|
|
"*", // Wildcard
|
|
"special_keys.#", // Array length operator
|
|
"special_keys.@reverse", // Modifier
|
|
"special_keys|@pretty", // Pipe operator
|
|
"arrays.mixed.#.name", // Multi-value syntax
|
|
"arrays.mixed.[0,1]", // Union syntax
|
|
}
|
|
|
|
for _, path := range dangerousPaths {
|
|
t.Run(path, func(t *testing.T) {
|
|
// These should either work correctly or fail gracefully
|
|
_, _ = config.GetString(path)
|
|
_, _ = config.Get(path)
|
|
// We're just ensuring no panic occurs
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGjsonPathSecurity(t *testing.T) {
|
|
yamlContent := `
|
|
sensitive:
|
|
password: "secret123"
|
|
api_key: "sk-1234567890"
|
|
|
|
public:
|
|
version: "1.0.0"
|
|
`
|
|
|
|
config, err := NewFromReader(strings.NewReader(yamlContent))
|
|
if err != nil {
|
|
t.Fatalf("Failed to load config: %v", err)
|
|
}
|
|
|
|
t.Run("Path traversal attempts", func(t *testing.T) {
|
|
// Ensure path traversal doesn't work
|
|
traversalPaths := []string{
|
|
"../sensitive/password",
|
|
"public/../sensitive/password",
|
|
"./sensitive/password",
|
|
"public/../../sensitive/password",
|
|
}
|
|
|
|
for _, path := range traversalPaths {
|
|
t.Run(path, func(t *testing.T) {
|
|
// These should not access sensitive data through traversal
|
|
val, _ := config.GetString(path)
|
|
if val == "secret123" {
|
|
t.Errorf("Path traversal %q should not access sensitive data", path)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGjsonPerformanceEdgeCases(t *testing.T) {
|
|
// Test with large configurations
|
|
var largeYaml strings.Builder
|
|
largeYaml.WriteString("large_array:\n")
|
|
for i := 0; i < 1000; i++ {
|
|
largeYaml.WriteString(" - id: ")
|
|
largeYaml.WriteString(strconv.Itoa(i))
|
|
largeYaml.WriteString("\n")
|
|
}
|
|
|
|
largeYaml.WriteString("large_map:\n")
|
|
for i := 0; i < 100; i++ {
|
|
largeYaml.WriteString(fmt.Sprintf(" key%d: value%d\n", i, i))
|
|
}
|
|
|
|
config, err := NewFromReader(strings.NewReader(largeYaml.String()))
|
|
if err != nil {
|
|
t.Fatalf("Failed to load large config: %v", err)
|
|
}
|
|
|
|
// Access various elements
|
|
_, err = config.GetString("large_array.999.id")
|
|
if err != nil {
|
|
t.Errorf("Failed to access last array element: %v", err)
|
|
}
|
|
|
|
_, err = config.GetString("large_map.key99")
|
|
if err != nil {
|
|
t.Errorf("Failed to access map element: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestResolverArgumentValidation(t *testing.T) {
|
|
yamlContent := `
|
|
test: value
|
|
`
|
|
|
|
config, err := NewFromReader(strings.NewReader(yamlContent))
|
|
if err != nil {
|
|
t.Fatalf("Failed to load config: %v", err)
|
|
}
|
|
|
|
t.Run("Path with interpolation syntax", func(t *testing.T) {
|
|
// Paths that look like interpolation should be handled correctly
|
|
suspiciousPaths := []string{
|
|
"${ENV:PATH}", // Interpolation syntax in path
|
|
"test${ENV:SUFFIX}", // Partial interpolation
|
|
"${test}", // Brace syntax
|
|
"$test", // Dollar sign
|
|
"test$", // Trailing dollar
|
|
}
|
|
|
|
for _, path := range suspiciousPaths {
|
|
t.Run(path, func(t *testing.T) {
|
|
_, _ = config.GetString(path)
|
|
// Should not panic or cause issues
|
|
})
|
|
}
|
|
})
|
|
}
|