The template's files at a77fd30, without its history or LICENSE, after script/rename simplexcalc. Model: opus-5-5
258 lines
7.1 KiB
Go
258 lines
7.1 KiB
Go
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)
|
|
}
|
|
}
|