package config_test import ( "errors" "strings" "testing" "time" "github.com/spf13/viper" "sneak.berlin/go/simplexcalc/internal/config" ) // Credentials used by the metrics-auth cases. const ( testUser = "scraper" testPass = "hunter2" ) // env builds a viper instance holding exactly the given keys, so a test // describes one environment without touching the process's. func env(kv map[string]string) *viper.Viper { v := viper.New() for k, val := range kv { v.Set(k, val) } return v } // TestAbsentValuesTakeDefaults pins the other half of the iron rule: a // value that is not set does get the default. Without this, a bug that // rejected everything would pass every test below. func TestAbsentValuesTakeDefaults(t *testing.T) { t.Parallel() c, err := config.Load(env(nil)) if err != nil { t.Fatalf("empty environment must be valid, got: %v", err) } if c.Port != int(config.DefaultPort) { t.Errorf("Port = %d, want %d", c.Port, config.DefaultPort) } if c.MaxRequestBody != config.DefaultMaxRequestBody { t.Errorf("MaxRequestBody = %d, want %d", c.MaxRequestBody, config.DefaultMaxRequestBody) } if c.RequestTimeout != config.DefaultRequestTimeout { t.Errorf("RequestTimeout = %s, want %s", c.RequestTimeout, config.DefaultRequestTimeout) } if !c.HSTS { t.Error("HSTS must default on when DEBUG is not set") } if !c.CSRFKeyEphemeral { t.Error("an absent CSRF_KEY must mark the config for an ephemeral key") } } // TestSetButUnparseableAborts is the central contract of this package. // Every case is a value an operator plausibly types, and every one of // them must fail startup rather than be replaced by the default. func TestSetButUnparseableAborts(t *testing.T) { t.Parallel() cases := map[string]map[string]string{ "port is not a number": {config.EnvPort: "eighty"}, "port is zero": {config.EnvPort: "0"}, "port is above the range": {config.EnvPort: "70000"}, "port is a float": {config.EnvPort: "8080.0"}, "debug is yes": {config.EnvDebug: "yes"}, "hsts is on": {config.EnvHSTS: "on"}, "body cap is nonsense": {config.EnvMaxRequestBody: "big"}, "body cap is too large": {config.EnvMaxRequestBody: "1TiB"}, "body cap is too small": {config.EnvMaxRequestBody: "10"}, "timeout has no unit": {config.EnvRequestTimeout: "30"}, "timeout is out of range": {config.EnvRequestTimeout: "1h"}, "grace is nonsense": {config.EnvShutdownGrace: "soon"}, "sentry dsn is not a url": {config.EnvSentryDSN: "not a dsn"}, "csrf key is not hex": {config.EnvCSRFKey: "not-hex-at-all"}, "csrf key is wrong length": {config.EnvCSRFKey: "abcdef"}, } for name, kv := range cases { t.Run(name, func(t *testing.T) { t.Parallel() c, err := config.Load(env(kv)) if err == nil { t.Fatalf("wanted a startup failure, got a Config: %+v", c) } if !errors.Is(err, config.ErrInvalidConfig) { t.Errorf("error does not wrap ErrInvalidConfig: %v", err) } if c != nil { t.Error("a failed load must return no Config at all") } }) } } // TestSecretsAreNotEchoed: a rejected CSRF key must not appear in the // error, because errors are logged and a log is not a place to put a // key. func TestSecretsAreNotEchoed(t *testing.T) { t.Parallel() const secret = "00112233445566778899aabbccdd" // valid hex, wrong length _, err := config.Load(env(map[string]string{config.EnvCSRFKey: secret})) if err == nil { t.Fatal("wanted a failure for a short CSRF key") } if strings.Contains(err.Error(), secret) { t.Errorf("the rejected key was echoed in the error: %v", err) } } // TestHalfSetMetricsAuthAborts covers the case the issue calls out // explicitly: auth config that is half-set must fail loudly, in both // directions. func TestHalfSetMetricsAuthAborts(t *testing.T) { t.Parallel() cases := map[string]map[string]string{ "user without password": {config.EnvMetricsUser: testUser}, "password without user": {config.EnvMetricsPassword: testPass}, } for name, kv := range cases { t.Run(name, func(t *testing.T) { t.Parallel() _, err := config.Load(env(kv)) if err == nil { t.Fatal("half-set metrics credentials must abort startup") } if !errors.Is(err, config.ErrInvalidConfig) { t.Errorf("error does not wrap ErrInvalidConfig: %v", err) } }) } both, err := config.Load(env(map[string]string{ config.EnvMetricsUser: testUser, config.EnvMetricsPassword: testPass, })) if err != nil { t.Fatalf("both credentials set must be valid, got: %v", err) } if both.MetricsUser != testUser || both.MetricsPassword != testPass { t.Error("credentials did not survive parsing") } neither, err := config.Load(env(nil)) if err != nil { t.Fatalf("neither credential set must be valid, got: %v", err) } if neither.MetricsUser != "" || neither.MetricsPassword != "" { t.Error("credentials appeared from nowhere") } } // TestEveryFailureIsReported: one restart should surface the whole list, // not just the first problem. func TestEveryFailureIsReported(t *testing.T) { t.Parallel() _, err := config.Load(env(map[string]string{ config.EnvPort: "eighty", config.EnvDebug: "yes", config.EnvRequestTimeout: "soon", })) if err == nil { t.Fatal("wanted failures") } for _, key := range []string{ config.EnvPort, config.EnvDebug, config.EnvRequestTimeout, } { if !strings.Contains(err.Error(), key) { t.Errorf("%s is broken but is not named in the error: %v", key, err) } } } // TestValidValuesAreUsed proves the parsers accept what they document. func TestValidValuesAreUsed(t *testing.T) { t.Parallel() const key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" c, err := config.Load(env(map[string]string{ config.EnvPort: "9000", config.EnvDebug: "true", config.EnvMaxRequestBody: "2MiB", config.EnvRequestTimeout: "45s", config.EnvShutdownGrace: "5s", config.EnvDataDir: "/var/lib/example", config.EnvCSRFKey: key, })) if err != nil { t.Fatalf("valid environment was rejected: %v", err) } if c.Port != 9000 { t.Errorf("Port = %d, want 9000", c.Port) } if c.MaxRequestBody != 2<<20 { t.Errorf("MaxRequestBody = %d, want %d", c.MaxRequestBody, 2<<20) } if c.RequestTimeout != 45*time.Second { t.Errorf("RequestTimeout = %s, want 45s", c.RequestTimeout) } if c.HSTS { t.Error("HSTS must default off when DEBUG is true") } if len(c.CSRFKey) != config.CSRFKeyBytes || c.CSRFKeyEphemeral { t.Errorf("CSRFKey not decoded: len=%d ephemeral=%v", len(c.CSRFKey), c.CSRFKeyEphemeral) } if c.DBPath != "/var/lib/example/simplexcalc.db" { t.Errorf("DBPath = %q, want it derived from DATA_DIR", c.DBPath) } } // TestExplicitOverridesDerivedDBPath: DB_PATH wins over the DATA_DIR // derivation, which is the only reason it exists. func TestExplicitOverridesDerivedDBPath(t *testing.T) { t.Parallel() c, err := config.Load(env(map[string]string{ config.EnvDataDir: "/var/lib/example", config.EnvDBPath: "/srv/other.db", })) if err != nil { t.Fatalf("valid environment was rejected: %v", err) } if c.DBPath != "/srv/other.db" { t.Errorf("DBPath = %q, want /srv/other.db", c.DBPath) } }