403 lines
12 KiB
Go
403 lines
12 KiB
Go
package config
|
|
|
|
import (
|
|
"errors"
|
|
"math"
|
|
"net"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestCheckedConversionsAndRollback(t *testing.T) {
|
|
type limits struct {
|
|
U uint64 `toml:"u"`
|
|
I int64 `toml:"i"`
|
|
B uint8 `toml:"b"`
|
|
F float64 `toml:"f"`
|
|
}
|
|
initial := limits{1, 2, 3, 4}
|
|
for _, input := range []map[string]any{
|
|
{"u": "18446744073709551616"}, {"u": int64(-1)}, {"i": uint64(math.MaxUint64)},
|
|
{"i": float64(0x1p63)}, {"u": float64(0x1p64)}, {"b": 256}, {"i": 1.5},
|
|
{"f": uint64(1<<53) + 1}, {"f": math.NaN()}, {"f": "Inf"},
|
|
} {
|
|
got := initial
|
|
if err := ScanMap(input, &got); err == nil || got != initial {
|
|
t.Fatalf("accepted or published invalid %#v: %+v (%v)", input, got, err)
|
|
}
|
|
}
|
|
var got limits
|
|
if err := ScanMap(map[string]any{"u": "18446744073709551615", "i": int64(math.MinInt64), "b": 255}, &got); err != nil || got.U != math.MaxUint64 || got.I != math.MinInt64 || got.B != 255 {
|
|
t.Fatalf("boundaries: %+v %v", got, err)
|
|
}
|
|
var array struct {
|
|
Values [2]uint16 `toml:"values"`
|
|
}
|
|
if err := ScanMap(map[string]any{"values": []any{"1", "65535"}}, &array); err != nil || array.Values != [2]uint16{1, 65535} {
|
|
t.Fatalf("array: %+v %v", array, err)
|
|
}
|
|
}
|
|
|
|
func TestOwnedValuesAndSnapshots(t *testing.T) {
|
|
type settings struct {
|
|
Values map[string][]int `toml:"values"`
|
|
Port uint16 `toml:"port"`
|
|
}
|
|
target := settings{Values: map[string][]int{"a": {1}}, Port: 80}
|
|
c, err := NewBuilder().WithTarget(&target).WithArgs(nil).Build()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
target.Values["a"][0] = 99
|
|
first, err := c.AsStruct()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if first.(*settings).Values["a"][0] != 1 {
|
|
t.Fatal("target defaults alias internal values")
|
|
}
|
|
first.(*settings).Values["a"][0] = 88
|
|
raw, _ := c.Get("values")
|
|
raw.(map[string][]int)["a"][0] = 77
|
|
source := map[string][]int{"a": {2}}
|
|
if err := c.Set("values", source); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
source["a"][0] = 66
|
|
second, err := c.AsStruct()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if second.(*settings).Values["a"][0] != 2 || first.(*settings).Values["a"][0] != 88 {
|
|
t.Fatal("snapshot changed or input leaked")
|
|
}
|
|
clone := c.Clone()
|
|
v, _ := clone.Get("values")
|
|
v.(map[string][]int)["a"][0] = 44
|
|
if err := clone.Set("values", map[string][]int{"a": {3}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
third, _ := c.AsStruct()
|
|
if third.(*settings).Values["a"][0] != 2 {
|
|
t.Fatal("clone aliases original")
|
|
}
|
|
var wg sync.WaitGroup
|
|
for range 4 {
|
|
wg.Go(func() {
|
|
for range 40 {
|
|
s, err := c.AsStruct()
|
|
if err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
s.(*settings).Values["a"][0]++
|
|
}
|
|
})
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
func TestAtomicValuesAndFileRoundTrip(t *testing.T) {
|
|
type settings struct {
|
|
IP net.IP `toml:"ip"`
|
|
Network net.IPNet `toml:"network"`
|
|
URL url.URL `toml:"url"`
|
|
Time *time.Time `toml:"time"`
|
|
Delay time.Duration `toml:"delay"`
|
|
}
|
|
_, network, _ := net.ParseCIDR("10.2.0.0/16")
|
|
endpoint, _ := url.Parse("https://user:pass@example.com/path?q=x")
|
|
stamp := time.Date(2026, 9, 28, 12, 0, 0, 123, time.UTC)
|
|
initial := settings{net.ParseIP("::1"), *network, *endpoint, &stamp, 1500 * time.Millisecond}
|
|
c := New()
|
|
if err := c.RegisterStruct("", initial); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
path := filepath.Join(t.TempDir(), "config.toml")
|
|
if err := c.Save(path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
next := New()
|
|
if err := next.RegisterStruct("", settings{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := next.LoadFile(path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var got settings
|
|
if err := next.Scan(&got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(got, initial) {
|
|
t.Fatalf("atomic roundtrip: %+v != %+v", got, initial)
|
|
}
|
|
for _, bad := range []any{net.IP{1}, net.IPNet{IP: net.IP{1}, Mask: net.IPMask{255}}, math.NaN(), make(chan int)} {
|
|
if err := c.Register("bad", bad); err == nil {
|
|
t.Fatalf("accepted %T", bad)
|
|
}
|
|
}
|
|
cycle := map[string]any{}
|
|
cycle["self"] = cycle
|
|
if err := c.Register("cycle", cycle); err == nil {
|
|
t.Fatal("accepted cycle")
|
|
}
|
|
var p any
|
|
p = &p
|
|
if err := decodeConfig(p, new(int)); err == nil {
|
|
t.Fatal("accepted cyclic pointer")
|
|
}
|
|
}
|
|
|
|
func TestSourceReplacementIsTransactional(t *testing.T) {
|
|
c := New()
|
|
_ = c.Register("port", uint16(80))
|
|
_ = c.Register("name", "default")
|
|
if err := c.LoadCLI([]string{"--port=8080", "--name=old"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := c.LoadCLI([]string{"--name=new", "--port=65536"}); err == nil {
|
|
t.Fatal("accepted overflow")
|
|
}
|
|
if v, _ := c.Get("name"); v != "old" {
|
|
t.Fatal("partially published CLI")
|
|
}
|
|
if err := c.LoadCLI([]string{"--port=90", "--", "--name=ignored"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if v, _ := c.Get("name"); v != "default" {
|
|
t.Fatal("stale CLI value")
|
|
}
|
|
t.Setenv("AUDIT_PORT", "100")
|
|
if err := c.LoadEnv("AUDIT_"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Unsetenv("AUDIT_PORT"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := c.LoadEnv("AUDIT_"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := c.GetSource("port", SourceEnv); ok {
|
|
t.Fatal("removed environment variable retained")
|
|
}
|
|
path := filepath.Join(t.TempDir(), "c.toml")
|
|
writeTestFile(t, path, []byte("port=100\nname=\"valid\""), 0600)
|
|
if err := c.LoadFile(path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeTestFile(t, path, []byte("port=65536\nname=\"invalid\""), 0600)
|
|
if err := c.LoadFile(path); err == nil {
|
|
t.Fatal("accepted invalid file")
|
|
}
|
|
if v, _ := c.GetSource("name", SourceFile); v != "valid" {
|
|
t.Fatal("partially published file")
|
|
}
|
|
if err := c.SetSource(Source("invalid"), "name", "x"); err == nil {
|
|
t.Fatal("accepted invalid source")
|
|
}
|
|
if err := c.Register("name.child", "x"); err == nil {
|
|
t.Fatal("accepted overlapping path")
|
|
}
|
|
}
|
|
|
|
func TestSaveCommentsPermissionsAndFailure(t *testing.T) {
|
|
c := New()
|
|
_ = c.Register("value", uint64(0))
|
|
_ = c.Register("text", "")
|
|
path := filepath.Join(t.TempDir(), "c.toml")
|
|
original := "# heading\nvalue=7 # inline\ntext=\"hash # inside\"\n"
|
|
writeTestFile(t, path, []byte(original), 0600)
|
|
if err := c.LoadFile(path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := c.Save(path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data, _ := os.ReadFile(path)
|
|
info, _ := os.Stat(path)
|
|
if strings.Count(string(data), "# heading") != 1 || strings.Count(string(data), "# inline") != 1 || !strings.Contains(string(data), `hash # inside`) || info.Mode().Perm() != 0600 {
|
|
t.Fatalf("save: %s mode=%v", data, info.Mode())
|
|
}
|
|
before := string(data)
|
|
if err := c.Set("value", uint64(math.MaxUint64)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := c.Save(path); !errors.Is(err, ErrFileFormat) {
|
|
t.Fatalf("expected range error, got %v", err)
|
|
}
|
|
after, _ := os.ReadFile(path)
|
|
if string(after) != before {
|
|
t.Fatal("failed save changed destination")
|
|
}
|
|
}
|
|
|
|
func FuzzConfigNumericConversions(f *testing.F) {
|
|
for _, v := range []uint64{0, 255, 256, 1 << 63, math.MaxUint64} {
|
|
f.Add(v)
|
|
}
|
|
f.Fuzz(func(t *testing.T, n uint64) {
|
|
var u uint64
|
|
if err := decodeConfig(n, &u); err != nil || u != n {
|
|
t.Fatalf("uint64 loss %d %v", u, err)
|
|
}
|
|
var b uint8
|
|
err := decodeConfig(n, &b)
|
|
if (err == nil) != (n <= 255) || err == nil && uint64(b) != n {
|
|
t.Fatalf("narrowing %d %v", b, err)
|
|
}
|
|
var i int64
|
|
err = decodeConfig(n, &i)
|
|
if (err == nil) != (n <= math.MaxInt64) || err == nil && uint64(i) != n {
|
|
t.Fatalf("signed %d %v", i, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRegistrationAndRequiredContracts(t *testing.T) {
|
|
type settings struct {
|
|
Port uint16 `toml:"port" required:"true"`
|
|
private string
|
|
}
|
|
target := settings{Port: 80}
|
|
if _, err := NewBuilder().WithTarget(&target).WithArgs(nil).Build(); !errors.Is(err, ErrValidation) {
|
|
t.Fatalf("required field ignored: %v", err)
|
|
}
|
|
c, err := NewBuilder().WithTarget(&target).WithArgs([]string{"--port=80"}).Build()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := c.AsStruct(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := c.SetPrecedence(SourceDefault, SourceCLI); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := c.Validate(); err == nil {
|
|
t.Fatal("inactive override satisfied required field")
|
|
}
|
|
type wrong struct {
|
|
Port uint64 `toml:"port"`
|
|
}
|
|
if _, err := NewBuilder().WithTarget(&target).WithDefaults(wrong{}).Build(); err == nil {
|
|
t.Fatal("mismatched target schema accepted")
|
|
}
|
|
type duplicate struct {
|
|
A int `toml:"same"`
|
|
B int `toml:"same"`
|
|
}
|
|
empty := New()
|
|
if err := empty.RegisterStruct("", duplicate{}); err == nil || len(empty.GetRegisteredPaths()) != 0 {
|
|
t.Fatal("partial or duplicate schema accepted")
|
|
}
|
|
for _, n := range []float64{math.NaN(), math.Inf(1)} {
|
|
if Positive(n) == nil || NonNegative(n) == nil || Range[float64](0, 10)(n) == nil {
|
|
t.Fatal("non-finite validator input accepted")
|
|
}
|
|
}
|
|
}
|
|
|
|
// A string from a file or a map is one list entry, as a pattern or a DN may
|
|
// hold commas; environment and command-line text splits at commas
|
|
func TestOnlyTextSourcesSplitLists(t *testing.T) {
|
|
type C struct {
|
|
Patterns []string `toml:"patterns"`
|
|
Hosts []string `toml:"hosts"`
|
|
Tags []string `toml:"tags"`
|
|
}
|
|
path := filepath.Join(t.TempDir(), "c.toml")
|
|
writeTestFile(t, path, []byte(`patterns = "password=\\S{8,64}"`+"\n"), 0600)
|
|
t.Setenv("ZZ_HOSTS", "a,b")
|
|
cfg, err := NewBuilder().WithTarget(&C{}).WithEnvPrefix("ZZ_").WithArgs([]string{"--tags=x,y"}).WithFile(path).Build()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
v, err := cfg.AsStruct()
|
|
got := v.(*C)
|
|
if err != nil || !reflect.DeepEqual(got.Patterns, []string{`password=\S{8,64}`}) ||
|
|
!reflect.DeepEqual(got.Hosts, []string{"a", "b"}) || !reflect.DeepEqual(got.Tags, []string{"x", "y"}) {
|
|
t.Fatalf("%+v %v", got, err)
|
|
}
|
|
var m C
|
|
if err := ScanMap(map[string]any{"patterns": "m,n"}, &m); err != nil || !reflect.DeepEqual(m.Patterns, []string{"m,n"}) {
|
|
t.Fatalf("ScanMap: %+v %v", m, err)
|
|
}
|
|
}
|
|
|
|
// A failed conversion names the field, never its value: a secret misplaced
|
|
// into a numeric field would reach logs and watch events
|
|
func TestConversionErrorsOmitValues(t *testing.T) {
|
|
type C struct {
|
|
Port int64 `toml:"port"`
|
|
Ratio float64 `toml:"ratio"`
|
|
On bool `toml:"on"`
|
|
Timeout time.Duration `toml:"timeout"`
|
|
}
|
|
for _, name := range []string{"ZZ_PORT", "ZZ_RATIO", "ZZ_ON", "ZZ_TIMEOUT"} {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Setenv(name, "hunter2-secret")
|
|
_, err := NewBuilder().WithTarget(&C{}).WithEnvPrefix("ZZ_").Build()
|
|
if err == nil || strings.Contains(err.Error(), "hunter2") {
|
|
t.Fatalf("err = %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Arguments that are no --flag are reported, not dropped, and a bare flag
|
|
// sets only a bool: "--dir --quiet" must not make dir "true"
|
|
func TestCommandLineReportsStrayArgsAndBareFlags(t *testing.T) {
|
|
type C struct {
|
|
Dir string `toml:"dir"`
|
|
Quiet bool `toml:"quiet"`
|
|
}
|
|
cfg, err := NewBuilder().WithTarget(&C{}).WithArgs([]string{"input.log", "-q", "--quiet", "--", "after"}).Build()
|
|
if err != nil || !reflect.DeepEqual(cfg.UnknownCLIKeys(), []string{"input.log", "-q"}) {
|
|
t.Fatalf("stray: %v %v", cfg.UnknownCLIKeys(), err)
|
|
}
|
|
if _, err := NewBuilder().WithTarget(&C{}).WithArgs([]string{"--dir", "--quiet"}).Build(); err == nil || !strings.Contains(err.Error(), "--dir needs a value") {
|
|
t.Fatalf("bare flag on a string path: %v", err)
|
|
}
|
|
}
|
|
|
|
// An empty variable is unset, and two paths cannot share one variable name
|
|
func TestEnvEmptyIsUnsetAndNamesAreUnique(t *testing.T) {
|
|
type C struct {
|
|
Port int64 `toml:"port"`
|
|
Tags []string `toml:"tags"`
|
|
}
|
|
t.Setenv("ZZ_PORT", "")
|
|
t.Setenv("ZZ_TAGS", "")
|
|
cfg, err := NewBuilder().WithTarget(&C{Port: 80, Tags: []string{"a"}}).WithEnvPrefix("ZZ_").Build()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if v, _ := cfg.AsStruct(); v.(*C).Port != 80 || !reflect.DeepEqual(v.(*C).Tags, []string{"a"}) {
|
|
t.Fatalf("empty variables changed values: %+v", v)
|
|
}
|
|
c := New()
|
|
c.Register("a.b_c", "")
|
|
c.Register("a_b.c", "")
|
|
t.Setenv("ZZ_A_B_C", "x")
|
|
if err := c.LoadEnv("ZZ_"); err == nil || !strings.Contains(err.Error(), "would set both") {
|
|
t.Fatalf("colliding names: %v", err)
|
|
}
|
|
}
|
|
|
|
// Build reports a load error under its source, not as file access
|
|
func TestBuildLabelsErrorsBySource(t *testing.T) {
|
|
type C struct {
|
|
Port int64 `toml:"port"`
|
|
}
|
|
t.Setenv("ZZ_PORT", "x")
|
|
_, err := NewBuilder().WithTarget(&C{}).WithEnvPrefix("ZZ_").Build()
|
|
if !errors.Is(err, ErrEnvParse) || errors.Is(err, ErrFileAccess) {
|
|
t.Fatalf("err = %v", err)
|
|
}
|
|
}
|