chore: update golangci-lint to v2.12.2 with canonical config (#54)
All checks were successful
check / check (push) Successful in 4s
All checks were successful
check / check (push) Successful in 4s
Canonical v2-schema `.golangci.yml`, golangci-lint pins bumped to v2.12.2 in `Dockerfile` and `script/bootstrap`, and the tree brought to `0 issues.` under it. Three behaviour deltas: `Cache.StoreVariant` takes a context (cancelled requests skip the accounting row, recovered by reconciliation); `MetadataStorage.Store` no longer leaks `.tmp-*.json` on Write/Close/Rename failure (dead-defer bug fix); the `signing_key` too-short error text gained a `value too short:` prefix. Eviction-loop context cancellation deferred to #102.
This commit was merged in pull request #54.
This commit is contained in:
@@ -2,7 +2,6 @@ package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -10,10 +9,16 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Static errors returned by the stub free-space probes below.
|
||||
var (
|
||||
errTestStatfsFailed = errors.New("statfs failed")
|
||||
errTestProbeNotExpected = errors.New("probe must not be called")
|
||||
)
|
||||
|
||||
// discardLogger returns a logger that swallows all output, for tests
|
||||
// that exercise code paths which log.
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return slog.New(slog.DiscardHandler)
|
||||
}
|
||||
|
||||
// TestCacheMaxBytesExplicitValueUsedWithoutFloor verifies that an
|
||||
@@ -21,6 +26,8 @@ func discardLogger() *slog.Logger {
|
||||
// given: the 500 MiB floor applies only to the computed default, never
|
||||
// to explicit values.
|
||||
func TestCacheMaxBytesExplicitValueUsedWithoutFloor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
yamlContent := "signing_key: " + validTestSigningKey + "\ncache_max_bytes: 1024\n"
|
||||
|
||||
c, err := configFromYAML(t, yamlContent)
|
||||
@@ -38,6 +45,8 @@ func TestCacheMaxBytesExplicitValueUsedWithoutFloor(t *testing.T) {
|
||||
// explicit zero is a valid value (it disables the disk cache), not an
|
||||
// error.
|
||||
func TestCacheMaxBytesZeroIsValidAndDisablesCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
yamlContent := "signing_key: " + validTestSigningKey + "\ncache_max_bytes: 0\n"
|
||||
|
||||
c, err := configFromYAML(t, yamlContent)
|
||||
@@ -53,7 +62,10 @@ func TestCacheMaxBytesZeroIsValidAndDisablesCache(t *testing.T) {
|
||||
// TestCacheMaxBytesLargeExplicitValueParses verifies that values above
|
||||
// 32-bit range parse correctly (the field is an int64 byte count).
|
||||
func TestCacheMaxBytesLargeExplicitValueParses(t *testing.T) {
|
||||
yamlContent := "signing_key: " + validTestSigningKey + "\ncache_max_bytes: 10737418240\n"
|
||||
t.Parallel()
|
||||
|
||||
yamlContent := "signing_key: " + validTestSigningKey +
|
||||
"\ncache_max_bytes: 10737418240\n"
|
||||
|
||||
c, err := configFromYAML(t, yamlContent)
|
||||
if err != nil {
|
||||
@@ -70,6 +82,8 @@ func TestCacheMaxBytesLargeExplicitValueParses(t *testing.T) {
|
||||
// offending value, per the no-silent-fallback rule: defaults apply
|
||||
// only to omitted keys.
|
||||
func TestCacheMaxBytesInvalidValuesAbortStartup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
signingKeyLine := "signing_key: " + validTestSigningKey + "\n"
|
||||
|
||||
cases := []struct {
|
||||
@@ -81,45 +95,48 @@ func TestCacheMaxBytesInvalidValuesAbortStartup(t *testing.T) {
|
||||
{
|
||||
name: "negative",
|
||||
yaml: signingKeyLine + "cache_max_bytes: -1024\n",
|
||||
wantErrSubstrings: []string{"cache_max_bytes", "-1024"},
|
||||
wantErrSubstrings: []string{keyCacheMaxBytes, "-1024"},
|
||||
},
|
||||
{
|
||||
name: "float",
|
||||
yaml: signingKeyLine + "cache_max_bytes: 3.5\n",
|
||||
wantErrSubstrings: []string{"cache_max_bytes", "3.5"},
|
||||
wantErrSubstrings: []string{keyCacheMaxBytes, "3.5"},
|
||||
},
|
||||
{
|
||||
name: "non-numeric string",
|
||||
yaml: signingKeyLine + "cache_max_bytes: banana\n",
|
||||
wantErrSubstrings: []string{"cache_max_bytes", "banana"},
|
||||
wantErrSubstrings: []string{keyCacheMaxBytes, "banana"},
|
||||
},
|
||||
{
|
||||
name: "explicit null",
|
||||
yaml: signingKeyLine + "cache_max_bytes: null\n",
|
||||
wantErrSubstrings: []string{"cache_max_bytes", "null"},
|
||||
wantErrSubstrings: []string{keyCacheMaxBytes, "null"},
|
||||
},
|
||||
{
|
||||
name: "bare key no value",
|
||||
yaml: signingKeyLine + "cache_max_bytes:\n",
|
||||
wantErrSubstrings: []string{"cache_max_bytes", "null"},
|
||||
wantErrSubstrings: []string{keyCacheMaxBytes, "null"},
|
||||
},
|
||||
{
|
||||
name: "boolean",
|
||||
yaml: signingKeyLine + "cache_max_bytes: true\n",
|
||||
wantErrSubstrings: []string{"cache_max_bytes", "true"},
|
||||
wantErrSubstrings: []string{keyCacheMaxBytes, "true"},
|
||||
},
|
||||
{
|
||||
name: "list",
|
||||
yaml: signingKeyLine + "cache_max_bytes:\n - 1\n",
|
||||
wantErrSubstrings: []string{"cache_max_bytes"},
|
||||
wantErrSubstrings: []string{keyCacheMaxBytes},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, err := configFromYAML(t, tc.yaml)
|
||||
if err == nil {
|
||||
t.Fatalf("config with %s cache_max_bytes must abort startup, got config: %+v",
|
||||
t.Fatalf(
|
||||
"config with %s cache_max_bytes must abort startup, got config: %+v",
|
||||
tc.name, c)
|
||||
}
|
||||
|
||||
@@ -138,6 +155,8 @@ func TestCacheMaxBytesInvalidValuesAbortStartup(t *testing.T) {
|
||||
// computed default is 75% of the probed free space when that exceeds
|
||||
// the floor.
|
||||
func TestComputeDefaultCacheMaxBytesUses75PercentOfFreeSpace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// 4 GiB free -> 3 GiB default.
|
||||
probe := func(string) (uint64, error) { return 4294967296, nil }
|
||||
|
||||
@@ -147,7 +166,8 @@ func TestComputeDefaultCacheMaxBytesUses75PercentOfFreeSpace(t *testing.T) {
|
||||
}
|
||||
|
||||
if got != 3221225472 {
|
||||
t.Errorf("ComputeDefaultCacheMaxBytes = %d, want 3221225472 (75%% of 4 GiB)", got)
|
||||
t.Errorf("ComputeDefaultCacheMaxBytes = %d, want 3221225472 (75%% of 4 GiB)",
|
||||
got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +175,8 @@ func TestComputeDefaultCacheMaxBytesUses75PercentOfFreeSpace(t *testing.T) {
|
||||
// verifies that when 75% of free space is below 500 MiB, the computed
|
||||
// default is floored at DefaultCacheMaxBytesFloor.
|
||||
func TestComputeDefaultCacheMaxBytesAppliesFloorToComputedDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
freeBytes uint64
|
||||
@@ -166,6 +188,8 @@ func TestComputeDefaultCacheMaxBytesAppliesFloorToComputedDefault(t *testing.T)
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
probe := func(string) (uint64, error) { return tc.freeBytes, nil }
|
||||
|
||||
got, err := ComputeDefaultCacheMaxBytes(t.TempDir(), probe)
|
||||
@@ -185,7 +209,9 @@ func TestComputeDefaultCacheMaxBytesAppliesFloorToComputedDefault(t *testing.T)
|
||||
// failing free-space probe produces an error naming the config key,
|
||||
// instead of a silently wrong default.
|
||||
func TestComputeDefaultCacheMaxBytesPropagatesProbeError(t *testing.T) {
|
||||
probe := func(string) (uint64, error) { return 0, errors.New("statfs failed") }
|
||||
t.Parallel()
|
||||
|
||||
probe := func(string) (uint64, error) { return 0, errTestStatfsFailed }
|
||||
|
||||
_, err := ComputeDefaultCacheMaxBytes(t.TempDir(), probe)
|
||||
if err == nil {
|
||||
@@ -194,7 +220,7 @@ func TestComputeDefaultCacheMaxBytesPropagatesProbeError(t *testing.T) {
|
||||
|
||||
t.Logf("got expected error: %v", err)
|
||||
|
||||
if !strings.Contains(err.Error(), "cache_max_bytes") {
|
||||
if !strings.Contains(err.Error(), keyCacheMaxBytes) {
|
||||
t.Errorf("error %q does not name the config key cache_max_bytes", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -205,6 +231,8 @@ func TestComputeDefaultCacheMaxBytesPropagatesProbeError(t *testing.T) {
|
||||
// first so statfs measures the right filesystem), and that the result
|
||||
// lands on the Config.
|
||||
func TestResolveCacheMaxBytesComputesDefaultWhenOmitted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, err := configFromYAML(t, "signing_key: "+validTestSigningKey+"\n")
|
||||
if err != nil {
|
||||
t.Fatalf("minimal config should be valid, got error: %v", err)
|
||||
@@ -222,16 +250,19 @@ func TestResolveCacheMaxBytesComputesDefaultWhenOmitted(t *testing.T) {
|
||||
return 4294967296, nil
|
||||
}
|
||||
|
||||
if err := c.resolveCacheMaxBytes(discardLogger(), probe); err != nil {
|
||||
err = c.resolveCacheMaxBytes(discardLogger(), probe)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveCacheMaxBytes returned error: %v", err)
|
||||
}
|
||||
|
||||
if c.CacheMaxBytes != 3221225472 {
|
||||
t.Errorf("CacheMaxBytes = %d, want computed default 3221225472", c.CacheMaxBytes)
|
||||
t.Errorf("CacheMaxBytes = %d, want computed default 3221225472",
|
||||
c.CacheMaxBytes)
|
||||
}
|
||||
|
||||
if probedPath != wantCacheDir {
|
||||
t.Errorf("free space probed at %q, want cache directory %q", probedPath, wantCacheDir)
|
||||
t.Errorf("free space probed at %q, want cache directory %q",
|
||||
probedPath, wantCacheDir)
|
||||
}
|
||||
|
||||
info, err := os.Stat(wantCacheDir)
|
||||
@@ -245,6 +276,8 @@ func TestResolveCacheMaxBytesComputesDefaultWhenOmitted(t *testing.T) {
|
||||
// an explicitly configured value survives resolution untouched and
|
||||
// that the free-space probe is never consulted for it.
|
||||
func TestResolveCacheMaxBytesDoesNotOverrideExplicitValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
yamlContent := "signing_key: " + validTestSigningKey + "\ncache_max_bytes: 1024\n"
|
||||
|
||||
c, err := configFromYAML(t, yamlContent)
|
||||
@@ -257,10 +290,11 @@ func TestResolveCacheMaxBytesDoesNotOverrideExplicitValue(t *testing.T) {
|
||||
probe := func(string) (uint64, error) {
|
||||
t.Error("free-space probe must not be consulted for explicit values")
|
||||
|
||||
return 0, errors.New("probe must not be called")
|
||||
return 0, errTestProbeNotExpected
|
||||
}
|
||||
|
||||
if err := c.resolveCacheMaxBytes(discardLogger(), probe); err != nil {
|
||||
err = c.resolveCacheMaxBytes(discardLogger(), probe)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveCacheMaxBytes returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -36,15 +36,17 @@ type FreeSpaceProbeFunc func(path string) (uint64, error)
|
||||
// the given path, as available to unprivileged processes.
|
||||
func defaultFreeSpaceProbe(path string) (uint64, error) {
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &stat); err != nil {
|
||||
|
||||
err := syscall.Statfs(path, &stat)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if stat.Bsize < 0 {
|
||||
return 0, fmt.Errorf("statfs reported negative block size %d for %q", stat.Bsize, path)
|
||||
return 0, fmt.Errorf("%w %d for %q", errNegativeBlockSize, stat.Bsize, path)
|
||||
}
|
||||
|
||||
blockSize := uint64(stat.Bsize) //nolint:gosec // G115: negative Bsize rejected above
|
||||
blockSize := uint64(stat.Bsize)
|
||||
|
||||
return stat.Bavail * blockSize, nil
|
||||
}
|
||||
@@ -52,7 +54,9 @@ func defaultFreeSpaceProbe(path string) (uint64, error) {
|
||||
// ComputeDefaultCacheMaxBytes returns the default cache size limit for
|
||||
// the filesystem containing cacheDir: 75% of the free bytes reported
|
||||
// by probe, with a floor of DefaultCacheMaxBytesFloor.
|
||||
func ComputeDefaultCacheMaxBytes(cacheDir string, probe FreeSpaceProbeFunc) (int64, error) {
|
||||
func ComputeDefaultCacheMaxBytes(
|
||||
cacheDir string, probe FreeSpaceProbeFunc,
|
||||
) (int64, error) {
|
||||
freeBytes, err := probe(cacheDir)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("config key %q: cannot determine free space for %q: %w",
|
||||
@@ -60,15 +64,14 @@ func ComputeDefaultCacheMaxBytes(cacheDir string, probe FreeSpaceProbeFunc) (int
|
||||
}
|
||||
|
||||
computed := freeBytes / freeSpaceFractionDenominator * freeSpaceFractionNumerator
|
||||
if computed > math.MaxInt64 {
|
||||
computed = math.MaxInt64
|
||||
}
|
||||
computed = min(computed, math.MaxInt64)
|
||||
|
||||
limit := int64(computed) //nolint:gosec // G115: clamped to MaxInt64 above
|
||||
|
||||
if limit < DefaultCacheMaxBytesFloor {
|
||||
limit = DefaultCacheMaxBytesFloor
|
||||
}
|
||||
// gosec cannot see that min() above bounds computed, so it reads
|
||||
// this conversion as potentially overflowing. It cannot: computed is
|
||||
// at most math.MaxInt64 on every path here.
|
||||
//nolint:gosec // G115: clamped to MaxInt64 by min above
|
||||
limit := int64(computed)
|
||||
limit = max(limit, DefaultCacheMaxBytesFloor)
|
||||
|
||||
return limit, nil
|
||||
}
|
||||
@@ -79,13 +82,16 @@ func ComputeDefaultCacheMaxBytes(cacheDir string, probe FreeSpaceProbeFunc) (int
|
||||
// on free space in <state_dir>/cache/. The cache directory is created
|
||||
// first so statfs measures the filesystem that will actually hold the
|
||||
// cache. The effective limit is logged either way.
|
||||
func (c *Config) resolveCacheMaxBytes(log *slog.Logger, probe FreeSpaceProbeFunc) error {
|
||||
func (c *Config) resolveCacheMaxBytes(
|
||||
log *slog.Logger, probe FreeSpaceProbeFunc,
|
||||
) error {
|
||||
if !c.cacheMaxBytesExplicit {
|
||||
cacheDir := filepath.Join(c.StateDir, "cache")
|
||||
|
||||
if err := os.MkdirAll(cacheDir, cacheDirPerms); err != nil {
|
||||
err := os.MkdirAll(cacheDir, cacheDirPerms)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config key %q: cannot create cache directory %q: %w",
|
||||
"cache_max_bytes", cacheDir, err)
|
||||
keyCacheMaxBytes, cacheDir, err)
|
||||
}
|
||||
|
||||
limit, err := ComputeDefaultCacheMaxBytes(cacheDir, probe)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
@@ -25,9 +26,59 @@ const (
|
||||
DefaultUpstreamConnectionsPerHost = 20
|
||||
)
|
||||
|
||||
// Configuration key names.
|
||||
const (
|
||||
keyDebug = "debug"
|
||||
keyMaintenanceMode = "maintenance_mode"
|
||||
keyPort = "port"
|
||||
keyStateDir = "state_dir"
|
||||
keySentryDSN = "sentry_dsn"
|
||||
keyDBURL = "db_url"
|
||||
keyMetrics = "metrics"
|
||||
keyMetricsUsername = "metrics.username"
|
||||
keyMetricsPassword = "metrics.password"
|
||||
keySigningKey = "signing_key"
|
||||
keyAllowlistHosts = "allowlist_hosts"
|
||||
keyAllowHTTP = "allow_http"
|
||||
keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
|
||||
keyCacheMaxBytes = "cache_max_bytes"
|
||||
)
|
||||
|
||||
// Static validation errors. Each use site attaches the offending key
|
||||
// and value by wrapping these with fmt.Errorf and %w.
|
||||
var (
|
||||
errValueRequired = errors.New("a value is required")
|
||||
errValueEmpty = errors.New("value must not be empty")
|
||||
errUnknownConfigKeys = errors.New("unknown config keys")
|
||||
errNotAString = errors.New("not a string")
|
||||
errNotAnInteger = errors.New("not an integer")
|
||||
errNotABoolean = errors.New("not a boolean")
|
||||
errNotAStringList = errors.New("not a list of strings")
|
||||
errNotAMetricsMap = errors.New("not a map of metrics settings")
|
||||
errEmptyListEntry = errors.New("list contains an empty entry")
|
||||
errEmptyEntry = errors.New("contains an empty entry")
|
||||
errNotAValidURL = errors.New("not a valid URL")
|
||||
errPortOutOfRange = errors.New("outside the valid port range")
|
||||
errTooFewConnections = errors.New("must be at least 1")
|
||||
errValueTooShort = errors.New("value too short")
|
||||
errMustBeSetTogether = errors.New("must be set together")
|
||||
errMustNotBeNegative = errors.New("must not be negative")
|
||||
errOverflowsInt64 = errors.New("overflows a 64-bit integer")
|
||||
errNegativeBlockSize = errors.New(
|
||||
"statfs reported negative block size")
|
||||
errValueNull = errors.New(
|
||||
"value is null; omit the key entirely to use the default")
|
||||
errValuesNull = errors.New(
|
||||
"value is null; omit a key entirely to use its default")
|
||||
errNotBareHostname = errors.New(
|
||||
"must be a bare hostname without scheme, path, or whitespace")
|
||||
errNoHostnameLabels = errors.New("contains no hostname labels")
|
||||
)
|
||||
|
||||
// Params defines dependencies for Config.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Globals *globals.Globals
|
||||
Logger *logger.Logger
|
||||
}
|
||||
@@ -82,11 +133,13 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := c.ensureStateDirWritable(); err != nil {
|
||||
err = c.ensureStateDirWritable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := c.resolveCacheMaxBytes(log, defaultFreeSpaceProbe); err != nil {
|
||||
err = c.resolveCacheMaxBytes(log, defaultFreeSpaceProbe)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -104,11 +157,13 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
|
||||
// to omitted keys, never to invalid explicit values.
|
||||
func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
||||
if sc != nil {
|
||||
if err := validateKnownKeys(sc); err != nil {
|
||||
err := validateKnownKeys(sc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := validateAllowlistHostsValue(sc); err != nil {
|
||||
err = validateAllowlistHostsValue(sc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -116,26 +171,26 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
||||
loader := &strictLoader{sc: sc}
|
||||
|
||||
c := &Config{
|
||||
Debug: loader.boolVal("debug", false),
|
||||
MaintenanceMode: loader.boolVal("maintenance_mode", false),
|
||||
Port: loader.intVal("port", DefaultPort),
|
||||
StateDir: loader.stringVal("state_dir", DefaultStateDir),
|
||||
SentryDSN: loader.stringVal("sentry_dsn", ""),
|
||||
MetricsUsername: loader.stringVal("metrics.username", ""),
|
||||
MetricsPassword: loader.stringVal("metrics.password", ""),
|
||||
SigningKey: loader.stringVal("signing_key", ""),
|
||||
AllowlistHosts: getStringSlice(sc, "allowlist_hosts"),
|
||||
AllowHTTP: loader.boolVal("allow_http", false),
|
||||
Debug: loader.boolVal(keyDebug, false),
|
||||
MaintenanceMode: loader.boolVal(keyMaintenanceMode, false),
|
||||
Port: loader.intVal(keyPort, DefaultPort),
|
||||
StateDir: loader.stringVal(keyStateDir, DefaultStateDir),
|
||||
SentryDSN: loader.stringVal(keySentryDSN, ""),
|
||||
MetricsUsername: loader.stringVal(keyMetricsUsername, ""),
|
||||
MetricsPassword: loader.stringVal(keyMetricsPassword, ""),
|
||||
SigningKey: loader.stringVal(keySigningKey, ""),
|
||||
AllowlistHosts: getStringSlice(sc),
|
||||
AllowHTTP: loader.boolVal(keyAllowHTTP, false),
|
||||
UpstreamConnectionsPerHost: loader.intVal(
|
||||
"upstream_connections_per_host", DefaultUpstreamConnectionsPerHost),
|
||||
CacheMaxBytes: loader.int64Val("cache_max_bytes", 0),
|
||||
keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost),
|
||||
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
|
||||
}
|
||||
|
||||
// The computed default for cache_max_bytes needs a validated
|
||||
// state_dir, so it is resolved later (resolveCacheMaxBytes); here
|
||||
// we only record whether the operator set the key explicitly.
|
||||
if sc != nil {
|
||||
if _, present := sc.Get("cache_max_bytes"); present {
|
||||
if _, present := sc.Get(keyCacheMaxBytes); present {
|
||||
c.cacheMaxBytesExplicit = true
|
||||
}
|
||||
}
|
||||
@@ -143,13 +198,13 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
||||
// Build DBURL from StateDir if not explicitly set. The derived URL
|
||||
// is a default: it applies only when db_url is omitted, never to an
|
||||
// explicitly empty value.
|
||||
c.DBURL = loader.stringVal("db_url", "")
|
||||
c.DBURL = loader.stringVal(keyDBURL, "")
|
||||
if c.DBURL == "" && loader.err == nil {
|
||||
if sc != nil {
|
||||
if _, present := sc.Get("db_url"); present {
|
||||
if _, present := sc.Get(keyDBURL); present {
|
||||
return nil, fmt.Errorf(
|
||||
"config key %q: value must not be empty; omit the key to derive it from state_dir",
|
||||
"db_url")
|
||||
"config key %q: %w; omit the key to derive it from state_dir",
|
||||
keyDBURL, errValueEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +215,8 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
||||
return nil, loader.err
|
||||
}
|
||||
|
||||
if err := c.validate(); err != nil {
|
||||
err := c.validate()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -189,23 +245,22 @@ func validateKnownKeys(sc *smartconfig.Config) error {
|
||||
continue
|
||||
}
|
||||
|
||||
if key == "metrics" {
|
||||
metricsMap, ok := value.(map[string]interface{})
|
||||
if key == keyMetrics {
|
||||
metricsMap, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf(
|
||||
"config key %q: value %v is not a map of metrics settings",
|
||||
"metrics", value)
|
||||
return fmt.Errorf("config key %q: value %v is %w",
|
||||
keyMetrics, value, errNotAMetricsMap)
|
||||
}
|
||||
|
||||
for subkey, subvalue := range metricsMap {
|
||||
if subkey != "username" && subkey != "password" {
|
||||
unknown = append(unknown, "metrics."+subkey)
|
||||
unknown = append(unknown, keyMetrics+"."+subkey)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if subvalue == nil {
|
||||
nullKeys = append(nullKeys, "metrics."+subkey)
|
||||
nullKeys = append(nullKeys, keyMetrics+"."+subkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,7 +269,7 @@ func validateKnownKeys(sc *smartconfig.Config) error {
|
||||
if len(unknown) > 0 {
|
||||
sort.Strings(unknown)
|
||||
|
||||
return fmt.Errorf("unknown config keys: %s", strings.Join(unknown, ", "))
|
||||
return fmt.Errorf("%w: %s", errUnknownConfigKeys, strings.Join(unknown, ", "))
|
||||
}
|
||||
|
||||
if len(nullKeys) > 0 {
|
||||
@@ -224,9 +279,8 @@ func validateKnownKeys(sc *smartconfig.Config) error {
|
||||
return errNullConfigValue(nullKeys[0])
|
||||
}
|
||||
|
||||
return fmt.Errorf(
|
||||
"config keys %s: value is null; omit a key entirely to use its default",
|
||||
strings.Join(nullKeys, ", "))
|
||||
return fmt.Errorf("config keys %s: %w",
|
||||
strings.Join(nullKeys, ", "), errValuesNull)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -236,17 +290,16 @@ func validateKnownKeys(sc *smartconfig.Config) error {
|
||||
// null (including the bare "key:" form and the "~" alias). Silently
|
||||
// applying the default would mask a truncated or typo'd config entry.
|
||||
func errNullConfigValue(key string) error {
|
||||
return fmt.Errorf(
|
||||
"config key %q: value is null; omit the key entirely to use the default", key)
|
||||
return fmt.Errorf("config key %q: %w", key, errValueNull)
|
||||
}
|
||||
|
||||
// isKnownConfigKey reports whether key is a permitted top-level
|
||||
// configuration key.
|
||||
func isKnownConfigKey(key string) bool {
|
||||
switch key {
|
||||
case "debug", "maintenance_mode", "port", "state_dir", "sentry_dsn",
|
||||
"db_url", "metrics", "signing_key", "allowlist_hosts", "allow_http",
|
||||
"upstream_connections_per_host", "cache_max_bytes", "env":
|
||||
case keyDebug, keyMaintenanceMode, keyPort, keyStateDir, keySentryDSN,
|
||||
keyDBURL, keyMetrics, keySigningKey, keyAllowlistHosts, keyAllowHTTP,
|
||||
keyUpstreamConnectionsPerHost, keyCacheMaxBytes, "env":
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -259,28 +312,30 @@ func isKnownConfigKey(key string) bool {
|
||||
func (c *Config) ensureStateDirWritable() error {
|
||||
const stateDirPerms = 0o750
|
||||
|
||||
if err := os.MkdirAll(c.StateDir, stateDirPerms); err != nil {
|
||||
err := os.MkdirAll(c.StateDir, stateDirPerms)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config key %q: cannot create directory %q: %w",
|
||||
"state_dir", c.StateDir, err)
|
||||
keyStateDir, c.StateDir, err)
|
||||
}
|
||||
|
||||
probe, err := os.CreateTemp(c.StateDir, ".startup-write-probe-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("config key %q: directory %q is not writable: %w",
|
||||
"state_dir", c.StateDir, err)
|
||||
keyStateDir, c.StateDir, err)
|
||||
}
|
||||
|
||||
probePath := probe.Name()
|
||||
|
||||
if err := probe.Close(); err != nil {
|
||||
err = probe.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("config key %q: cannot close probe file %q: %w",
|
||||
"state_dir", probePath, err)
|
||||
keyStateDir, probePath, err)
|
||||
}
|
||||
|
||||
//nolint:gosec // G703: probePath comes from os.CreateTemp inside the just-validated StateDir
|
||||
if err := os.Remove(probePath); err != nil {
|
||||
err = os.Remove(probePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config key %q: cannot remove probe file %q: %w",
|
||||
"state_dir", probePath, err)
|
||||
keyStateDir, probePath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -291,40 +346,42 @@ func (c *Config) ensureStateDirWritable() error {
|
||||
func (c *Config) validate() error {
|
||||
// The signing key value is never echoed in error messages.
|
||||
if c.SigningKey == "" {
|
||||
return fmt.Errorf("config key %q: a value is required", "signing_key")
|
||||
return fmt.Errorf("config key %q: %w", keySigningKey, errValueRequired)
|
||||
}
|
||||
|
||||
// Minimum key length for security (32 bytes = 256 bits)
|
||||
const minKeyLength = 32
|
||||
if len(c.SigningKey) < minKeyLength {
|
||||
return fmt.Errorf("config key %q: value must be at least %d characters, got %d",
|
||||
"signing_key", minKeyLength, len(c.SigningKey))
|
||||
return fmt.Errorf("config key %q: %w: must be at least %d characters, got %d",
|
||||
keySigningKey, errValueTooShort, minKeyLength, len(c.SigningKey))
|
||||
}
|
||||
|
||||
const maxPort = 65535
|
||||
if c.Port < 1 || c.Port > maxPort {
|
||||
return fmt.Errorf("config key %q: value %d is outside the valid port range 1-%d",
|
||||
"port", c.Port, maxPort)
|
||||
return fmt.Errorf("config key %q: value %d is %w 1-%d",
|
||||
keyPort, c.Port, errPortOutOfRange, maxPort)
|
||||
}
|
||||
|
||||
if c.UpstreamConnectionsPerHost < 1 {
|
||||
return fmt.Errorf("config key %q: value %d must be at least 1",
|
||||
"upstream_connections_per_host", c.UpstreamConnectionsPerHost)
|
||||
return fmt.Errorf("config key %q: value %d %w",
|
||||
keyUpstreamConnectionsPerHost, c.UpstreamConnectionsPerHost,
|
||||
errTooFewConnections)
|
||||
}
|
||||
|
||||
if c.StateDir == "" {
|
||||
return fmt.Errorf("config key %q: value must not be empty", "state_dir")
|
||||
return fmt.Errorf("config key %q: %w", keyStateDir, errValueEmpty)
|
||||
}
|
||||
|
||||
// Zero is valid (it disables the disk cache); only negative
|
||||
// values are rejected. No floor applies to explicit values.
|
||||
if c.CacheMaxBytes < 0 {
|
||||
return fmt.Errorf("config key %q: value %d must not be negative",
|
||||
"cache_max_bytes", c.CacheMaxBytes)
|
||||
return fmt.Errorf("config key %q: value %d %w",
|
||||
keyCacheMaxBytes, c.CacheMaxBytes, errMustNotBeNegative)
|
||||
}
|
||||
|
||||
for _, host := range c.AllowlistHosts {
|
||||
if err := validateAllowlistHost(host); err != nil {
|
||||
err := validateAllowlistHost(host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -332,14 +389,14 @@ func (c *Config) validate() error {
|
||||
if c.SentryDSN != "" {
|
||||
parsed, err := url.Parse(c.SentryDSN)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return fmt.Errorf("config key %q: value %q is not a valid URL",
|
||||
"sentry_dsn", c.SentryDSN)
|
||||
return fmt.Errorf("config key %q: value %q is %w",
|
||||
keySentryDSN, c.SentryDSN, errNotAValidURL)
|
||||
}
|
||||
}
|
||||
|
||||
if (c.MetricsUsername == "") != (c.MetricsPassword == "") {
|
||||
return fmt.Errorf("config keys %q and %q must be set together",
|
||||
"metrics.username", "metrics.password")
|
||||
return fmt.Errorf("config keys %q and %q %w",
|
||||
keyMetricsUsername, keyMetricsPassword, errMustBeSetTogether)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -354,21 +411,20 @@ func (c *Config) validate() error {
|
||||
// disable URL signing.
|
||||
func validateAllowlistHost(host string) error {
|
||||
if strings.Contains(host, "://") || strings.ContainsAny(host, "/ \t") {
|
||||
return fmt.Errorf(
|
||||
"config key %q: entry %q must be a bare hostname without scheme, path, or whitespace",
|
||||
"allowlist_hosts", host)
|
||||
return fmt.Errorf("config key %q: entry %q %w",
|
||||
keyAllowlistHosts, host, errNotBareHostname)
|
||||
}
|
||||
|
||||
if strings.Trim(host, ".") == "" {
|
||||
return fmt.Errorf(
|
||||
"config key %q: entry %q contains no hostname labels",
|
||||
"allowlist_hosts", host)
|
||||
return fmt.Errorf("config key %q: entry %q %w",
|
||||
keyAllowlistHosts, host, errNoHostnameLabels)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadConfigFile loads configuration from PIXA_CONFIG_PATH env var or standard locations.
|
||||
// loadConfigFile loads configuration from the PIXA_CONFIG_PATH env var
|
||||
// or standard locations.
|
||||
func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, error) {
|
||||
// Check for explicit config path from environment
|
||||
if envPath := os.Getenv("PIXA_CONFIG_PATH"); envPath != "" {
|
||||
@@ -394,8 +450,9 @@ func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, erro
|
||||
|
||||
for _, path := range configPaths {
|
||||
cleanPath := filepath.Clean(path)
|
||||
//nolint:gosec // G703: paths are hardcoded config locations
|
||||
if _, statErr := os.Stat(cleanPath); statErr == nil {
|
||||
|
||||
_, statErr := os.Stat(cleanPath)
|
||||
if statErr == nil {
|
||||
// A config file that exists but does not parse is a fatal
|
||||
// startup error, never something to skip over.
|
||||
sc, err := smartconfig.NewFromConfigPath(path)
|
||||
@@ -491,8 +548,8 @@ func getString(sc *smartconfig.Config, key, defaultVal string) (string, error) {
|
||||
|
||||
str, ok := raw.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("config key %q: value %v (%T) is not a string",
|
||||
key, raw, raw)
|
||||
return "", fmt.Errorf("config key %q: value %v (%T) is %w",
|
||||
key, raw, raw, errNotAString)
|
||||
}
|
||||
|
||||
return str, nil
|
||||
@@ -522,20 +579,22 @@ func getInt(sc *smartconfig.Config, key string, defaultVal int) (int, error) {
|
||||
return int(val), nil
|
||||
case float64:
|
||||
if val != math.Trunc(val) {
|
||||
return 0, fmt.Errorf("config key %q: value %v is not an integer", key, val)
|
||||
return 0, fmt.Errorf("config key %q: value %v is %w",
|
||||
key, val, errNotAnInteger)
|
||||
}
|
||||
|
||||
return int(val), nil
|
||||
case string:
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(val))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("config key %q: value %q is not an integer", key, val)
|
||||
return 0, fmt.Errorf("config key %q: value %q is %w",
|
||||
key, val, errNotAnInteger)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("config key %q: value %v (%T) is not an integer",
|
||||
key, raw, raw)
|
||||
return 0, fmt.Errorf("config key %q: value %v (%T) is %w",
|
||||
key, raw, raw, errNotAnInteger)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,27 +623,29 @@ func getInt64(sc *smartconfig.Config, key string, defaultVal int64) (int64, erro
|
||||
return val, nil
|
||||
case uint64:
|
||||
if val > math.MaxInt64 {
|
||||
return 0, fmt.Errorf("config key %q: value %d overflows a 64-bit integer",
|
||||
key, val)
|
||||
return 0, fmt.Errorf("config key %q: value %d %w",
|
||||
key, val, errOverflowsInt64)
|
||||
}
|
||||
|
||||
return int64(val), nil //nolint:gosec // G115: bounds checked above
|
||||
return int64(val), nil
|
||||
case float64:
|
||||
if val != math.Trunc(val) {
|
||||
return 0, fmt.Errorf("config key %q: value %v is not an integer", key, val)
|
||||
return 0, fmt.Errorf("config key %q: value %v is %w",
|
||||
key, val, errNotAnInteger)
|
||||
}
|
||||
|
||||
return int64(val), nil
|
||||
case string:
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(val), 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("config key %q: value %q is not an integer", key, val)
|
||||
return 0, fmt.Errorf("config key %q: value %q is %w",
|
||||
key, val, errNotAnInteger)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("config key %q: value %v (%T) is not an integer",
|
||||
key, raw, raw)
|
||||
return 0, fmt.Errorf("config key %q: value %v (%T) is %w",
|
||||
key, raw, raw, errNotAnInteger)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,13 +673,14 @@ func getBool(sc *smartconfig.Config, key string, defaultVal bool) (bool, error)
|
||||
case string:
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(val))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("config key %q: value %q is not a boolean", key, val)
|
||||
return false, fmt.Errorf("config key %q: value %q is %w",
|
||||
key, val, errNotABoolean)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
default:
|
||||
return false, fmt.Errorf("config key %q: value %v (%T) is not a boolean",
|
||||
key, raw, raw)
|
||||
return false, fmt.Errorf("config key %q: value %v (%T) is %w",
|
||||
key, raw, raw, errNotABoolean)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -628,28 +690,27 @@ func getBool(sc *smartconfig.Config, key string, defaultVal bool) (bool, error)
|
||||
// (or a comma-separated string), a non-string entry, or an empty entry
|
||||
// is an error, never silently skipped.
|
||||
func validateAllowlistHostsValue(sc *smartconfig.Config) error {
|
||||
const key = "allowlist_hosts"
|
||||
|
||||
raw, ok := sc.Get(key)
|
||||
raw, ok := sc.Get(keyAllowlistHosts)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if raw == nil {
|
||||
return errNullConfigValue(key)
|
||||
return errNullConfigValue(keyAllowlistHosts)
|
||||
}
|
||||
|
||||
switch val := raw.(type) {
|
||||
case []interface{}:
|
||||
case []any:
|
||||
for _, item := range val {
|
||||
str, ok := item.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf(
|
||||
"config key %q: list entry %v (%T) is not a string", key, item, item)
|
||||
return fmt.Errorf("config key %q: list entry %v (%T) is %w",
|
||||
keyAllowlistHosts, item, item, errNotAString)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(str) == "" {
|
||||
return fmt.Errorf("config key %q: list contains an empty entry", key)
|
||||
return fmt.Errorf("config key %q: %w",
|
||||
keyAllowlistHosts, errEmptyListEntry)
|
||||
}
|
||||
}
|
||||
case string:
|
||||
@@ -657,36 +718,36 @@ func validateAllowlistHostsValue(sc *smartconfig.Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, part := range strings.Split(val, ",") {
|
||||
for part := range strings.SplitSeq(val, ",") {
|
||||
if strings.TrimSpace(part) == "" {
|
||||
return fmt.Errorf(
|
||||
"config key %q: value %q contains an empty entry", key, val)
|
||||
return fmt.Errorf("config key %q: value %q %w",
|
||||
keyAllowlistHosts, val, errEmptyEntry)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("config key %q: value %v (%T) is not a list of strings",
|
||||
key, raw, raw)
|
||||
return fmt.Errorf("config key %q: value %v (%T) is %w",
|
||||
keyAllowlistHosts, raw, raw, errNotAStringList)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getStringSlice returns the list of strings for key, or nil if the key
|
||||
// is omitted. It accepts a YAML list of strings or a comma-separated
|
||||
// string (backwards compatibility). Malformed entries are rejected
|
||||
// beforehand by validateAllowlistHostsValue.
|
||||
func getStringSlice(sc *smartconfig.Config, key string) []string {
|
||||
// getStringSlice returns the allowlist_hosts list of strings, or nil if
|
||||
// the key is omitted. It accepts a YAML list of strings or a
|
||||
// comma-separated string (backwards compatibility). Malformed entries
|
||||
// are rejected beforehand by validateAllowlistHostsValue.
|
||||
func getStringSlice(sc *smartconfig.Config) []string {
|
||||
if sc == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
val, ok := sc.Get(key)
|
||||
val, ok := sc.Get(keyAllowlistHosts)
|
||||
if !ok || val == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle YAML list format
|
||||
if slice, ok := val.([]interface{}); ok {
|
||||
if slice, ok := val.([]any); ok {
|
||||
result := make([]string, 0, len(slice))
|
||||
for _, item := range slice {
|
||||
if str, ok := item.(string); ok {
|
||||
|
||||
98
internal/config/config_internal_test.go
Normal file
98
internal/config/config_internal_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/smartconfig"
|
||||
)
|
||||
|
||||
// writeTestConfig writes yamlContent to a temp config file and returns
|
||||
// the file path.
|
||||
func writeTestConfig(t *testing.T, yamlContent string) string {
|
||||
t.Helper()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yml")
|
||||
|
||||
err := os.WriteFile(configPath, []byte(yamlContent), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
return configPath
|
||||
}
|
||||
|
||||
// checkAllowlistHosts loads the config at configPath and asserts that
|
||||
// getStringSlice returns the three expected hosts.
|
||||
func checkAllowlistHosts(t *testing.T, configPath string) {
|
||||
t.Helper()
|
||||
|
||||
sc, err := loadTestConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
hosts := getStringSlice(sc)
|
||||
|
||||
if len(hosts) != 3 {
|
||||
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
|
||||
}
|
||||
|
||||
expected := []string{"static.sneak.cloud", "sneak.berlin", testHostS3}
|
||||
for i, want := range expected {
|
||||
if i >= len(hosts) {
|
||||
t.Errorf("missing host at index %d: want %q", i, want)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if hosts[i] != want {
|
||||
t.Errorf("host[%d] = %q, want %q", i, hosts[i], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStringSlice_YAMLList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
yamlContent := `
|
||||
allowlist_hosts:
|
||||
- static.sneak.cloud
|
||||
- sneak.berlin
|
||||
- s3.sneak.cloud
|
||||
`
|
||||
|
||||
checkAllowlistHosts(t, writeTestConfig(t, yamlContent))
|
||||
}
|
||||
|
||||
func TestGetStringSlice_CommaSeparated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Backwards compatibility with comma-separated string values.
|
||||
yamlContent := `allowlist_hosts: "static.sneak.cloud, sneak.berlin, s3.sneak.cloud"`
|
||||
|
||||
checkAllowlistHosts(t, writeTestConfig(t, yamlContent))
|
||||
}
|
||||
|
||||
func TestGetStringSlice_Empty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configPath := writeTestConfig(t, `port: 8080`)
|
||||
|
||||
sc, err := loadTestConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
hosts := getStringSlice(sc)
|
||||
if len(hosts) != 0 {
|
||||
t.Errorf("expected nil or empty slice, got %v", hosts)
|
||||
}
|
||||
}
|
||||
|
||||
// loadTestConfig is a helper to load a config file for testing.
|
||||
func loadTestConfig(path string) (*smartconfig.Config, error) {
|
||||
return smartconfig.NewFromConfigPath(path)
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/smartconfig"
|
||||
)
|
||||
|
||||
func TestGetStringSlice_YAMLList(t *testing.T) {
|
||||
// Create a temp config file with YAML list format
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yml")
|
||||
|
||||
yamlContent := `
|
||||
allowlist_hosts:
|
||||
- static.sneak.cloud
|
||||
- sneak.berlin
|
||||
- s3.sneak.cloud
|
||||
`
|
||||
err := os.WriteFile(configPath, []byte(yamlContent), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
// Load config using smartconfig
|
||||
sc, err := loadTestConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
// Test that getStringSlice correctly parses YAML list
|
||||
hosts := getStringSlice(sc, "allowlist_hosts")
|
||||
|
||||
if len(hosts) != 3 {
|
||||
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
|
||||
}
|
||||
|
||||
expected := []string{"static.sneak.cloud", "sneak.berlin", "s3.sneak.cloud"}
|
||||
for i, want := range expected {
|
||||
if i >= len(hosts) {
|
||||
t.Errorf("missing host at index %d: want %q", i, want)
|
||||
continue
|
||||
}
|
||||
if hosts[i] != want {
|
||||
t.Errorf("host[%d] = %q, want %q", i, hosts[i], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStringSlice_CommaSeparated(t *testing.T) {
|
||||
// Test backwards compatibility with comma-separated string
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yml")
|
||||
|
||||
yamlContent := `allowlist_hosts: "static.sneak.cloud, sneak.berlin, s3.sneak.cloud"`
|
||||
|
||||
err := os.WriteFile(configPath, []byte(yamlContent), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
sc, err := loadTestConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
hosts := getStringSlice(sc, "allowlist_hosts")
|
||||
|
||||
if len(hosts) != 3 {
|
||||
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
|
||||
}
|
||||
|
||||
expected := []string{"static.sneak.cloud", "sneak.berlin", "s3.sneak.cloud"}
|
||||
for i, want := range expected {
|
||||
if i >= len(hosts) {
|
||||
t.Errorf("missing host at index %d: want %q", i, want)
|
||||
continue
|
||||
}
|
||||
if hosts[i] != want {
|
||||
t.Errorf("host[%d] = %q, want %q", i, hosts[i], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStringSlice_Empty(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yml")
|
||||
|
||||
yamlContent := `port: 8080`
|
||||
|
||||
err := os.WriteFile(configPath, []byte(yamlContent), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
sc, err := loadTestConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
hosts := getStringSlice(sc, "allowlist_hosts")
|
||||
|
||||
if hosts != nil && len(hosts) != 0 {
|
||||
t.Errorf("expected nil or empty slice, got %v", hosts)
|
||||
}
|
||||
}
|
||||
|
||||
// loadTestConfig is a helper to load a config file for testing
|
||||
func loadTestConfig(path string) (*smartconfig.Config, error) {
|
||||
return smartconfig.NewFromConfigPath(path)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -15,6 +14,26 @@ import (
|
||||
// minimum length requirement in validate().
|
||||
const validTestSigningKey = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
// signingKeyLine is a valid signing_key config line used as the base of
|
||||
// test config files.
|
||||
const signingKeyLine = "signing_key: " + validTestSigningKey + "\n"
|
||||
|
||||
// testHostS3 is an allowlist host entry used across the config tests.
|
||||
const testHostS3 = "s3.sneak.cloud"
|
||||
|
||||
// nullValueText is the substring that error messages about explicitly
|
||||
// null config values must contain.
|
||||
const nullValueText = "null"
|
||||
|
||||
// abortCase describes a config file that must abort startup with an
|
||||
// error mentioning every string in wantErrSubstrings.
|
||||
type abortCase struct {
|
||||
name string
|
||||
yaml string
|
||||
// wantErrSubstrings must all appear in the error message.
|
||||
wantErrSubstrings []string
|
||||
}
|
||||
|
||||
// configFromYAML writes yamlContent to a temporary config file, loads it
|
||||
// via smartconfig, and constructs a Config from it using the same code
|
||||
// path the server uses at startup.
|
||||
@@ -24,7 +43,8 @@ func configFromYAML(t *testing.T, yamlContent string) (*Config, error) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yml")
|
||||
|
||||
if err := os.WriteFile(configPath, []byte(yamlContent), 0o600); err != nil {
|
||||
err := os.WriteFile(configPath, []byte(yamlContent), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
@@ -37,7 +57,9 @@ func configFromYAML(t *testing.T, yamlContent string) (*Config, error) {
|
||||
}
|
||||
|
||||
func TestOmittedValuesUseDefaults(t *testing.T) {
|
||||
c, err := configFromYAML(t, "signing_key: "+validTestSigningKey+"\n")
|
||||
t.Parallel()
|
||||
|
||||
c, err := configFromYAML(t, signingKeyLine)
|
||||
if err != nil {
|
||||
t.Fatalf("minimal config should be valid, got error: %v", err)
|
||||
}
|
||||
@@ -78,6 +100,8 @@ func TestOmittedValuesUseDefaults(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExplicitValidValuesAreUsed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
yamlContent := `
|
||||
port: 9090
|
||||
debug: true
|
||||
@@ -118,9 +142,10 @@ metrics:
|
||||
t.Errorf("DBURL = %q, want explicit value", c.DBURL)
|
||||
}
|
||||
|
||||
if len(c.AllowlistHosts) != 2 || c.AllowlistHosts[0] != "s3.sneak.cloud" ||
|
||||
if len(c.AllowlistHosts) != 2 || c.AllowlistHosts[0] != testHostS3 ||
|
||||
c.AllowlistHosts[1] != ".example.com" {
|
||||
t.Errorf("AllowlistHosts = %v, want [s3.sneak.cloud .example.com]", c.AllowlistHosts)
|
||||
t.Errorf("AllowlistHosts = %v, want [s3.sneak.cloud .example.com]",
|
||||
c.AllowlistHosts)
|
||||
}
|
||||
|
||||
if c.UpstreamConnectionsPerHost != 5 {
|
||||
@@ -138,8 +163,10 @@ metrics:
|
||||
}
|
||||
|
||||
func TestCommaSeparatedAllowlistStillSupported(t *testing.T) {
|
||||
yamlContent := `signing_key: ` + validTestSigningKey + `
|
||||
allowlist_hosts: "s3.sneak.cloud, sneak.berlin"
|
||||
t.Parallel()
|
||||
|
||||
yamlContent := signingKeyLine +
|
||||
`allowlist_hosts: "s3.sneak.cloud, sneak.berlin"
|
||||
`
|
||||
|
||||
c, err := configFromYAML(t, yamlContent)
|
||||
@@ -147,9 +174,160 @@ allowlist_hosts: "s3.sneak.cloud, sneak.berlin"
|
||||
t.Fatalf("comma-separated allowlist should load, got error: %v", err)
|
||||
}
|
||||
|
||||
if len(c.AllowlistHosts) != 2 || c.AllowlistHosts[0] != "s3.sneak.cloud" ||
|
||||
if len(c.AllowlistHosts) != 2 || c.AllowlistHosts[0] != testHostS3 ||
|
||||
c.AllowlistHosts[1] != "sneak.berlin" {
|
||||
t.Errorf("AllowlistHosts = %v, want [s3.sneak.cloud sneak.berlin]", c.AllowlistHosts)
|
||||
t.Errorf("AllowlistHosts = %v, want [s3.sneak.cloud sneak.berlin]",
|
||||
c.AllowlistHosts)
|
||||
}
|
||||
}
|
||||
|
||||
// runAbortCases asserts that each case's config aborts startup with an
|
||||
// error message mentioning every expected substring.
|
||||
func runAbortCases(t *testing.T, cases []abortCase) {
|
||||
t.Helper()
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, err := configFromYAML(t, tc.yaml)
|
||||
if err == nil {
|
||||
t.Fatalf("config with %s must abort startup, got config: %+v", tc.name, c)
|
||||
}
|
||||
|
||||
t.Logf("got expected error: %v", err)
|
||||
|
||||
for _, want := range tc.wantErrSubstrings {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error %q does not mention %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// invalidScalarValueCases are configs where a scalar key is explicitly
|
||||
// set to an unparseable or out-of-range value; each must abort startup
|
||||
// naming the offending key, never silently fall back to the default.
|
||||
func invalidScalarValueCases() []abortCase {
|
||||
return []abortCase{
|
||||
{
|
||||
name: "port not a number",
|
||||
yaml: signingKeyLine + "port: banana\n",
|
||||
wantErrSubstrings: []string{keyPort, "banana"},
|
||||
},
|
||||
{
|
||||
name: "port zero",
|
||||
yaml: signingKeyLine + "port: 0\n",
|
||||
wantErrSubstrings: []string{keyPort, "0"},
|
||||
},
|
||||
{
|
||||
name: "port above 65535",
|
||||
yaml: signingKeyLine + "port: 99999\n",
|
||||
wantErrSubstrings: []string{keyPort, "99999"},
|
||||
},
|
||||
{
|
||||
name: "port fractional",
|
||||
yaml: signingKeyLine + "port: 8080.5\n",
|
||||
wantErrSubstrings: []string{keyPort, "8080.5"},
|
||||
},
|
||||
{
|
||||
name: "debug not a bool",
|
||||
yaml: signingKeyLine + "debug: notabool\n",
|
||||
wantErrSubstrings: []string{keyDebug, "notabool"},
|
||||
},
|
||||
{
|
||||
name: "maintenance_mode not a bool",
|
||||
yaml: signingKeyLine + "maintenance_mode: sometimes\n",
|
||||
wantErrSubstrings: []string{keyMaintenanceMode, "sometimes"},
|
||||
},
|
||||
{
|
||||
name: "allow_http numeric",
|
||||
yaml: signingKeyLine + "allow_http: 2\n",
|
||||
wantErrSubstrings: []string{keyAllowHTTP, "2"},
|
||||
},
|
||||
{
|
||||
name: "upstream_connections_per_host zero",
|
||||
yaml: signingKeyLine + "upstream_connections_per_host: 0\n",
|
||||
wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, "0"},
|
||||
},
|
||||
{
|
||||
name: "upstream_connections_per_host negative",
|
||||
yaml: signingKeyLine + "upstream_connections_per_host: -3\n",
|
||||
wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, "-3"},
|
||||
},
|
||||
{
|
||||
name: "upstream_connections_per_host not a number",
|
||||
yaml: signingKeyLine + "upstream_connections_per_host: many\n",
|
||||
wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, "many"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// invalidHostAndCredentialCases are configs where allowlist_hosts,
|
||||
// signing_key, state_dir, sentry_dsn, or metrics is explicitly set to
|
||||
// an invalid value; each must abort startup naming the offending key.
|
||||
func invalidHostAndCredentialCases() []abortCase {
|
||||
return []abortCase{
|
||||
{
|
||||
name: "allowlist host with scheme",
|
||||
yaml: signingKeyLine + "allowlist_hosts:\n - https://example.com\n",
|
||||
wantErrSubstrings: []string{
|
||||
keyAllowlistHosts, "https://example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "allowlist host with path",
|
||||
yaml: signingKeyLine + "allowlist_hosts:\n - example.com/images\n",
|
||||
wantErrSubstrings: []string{
|
||||
keyAllowlistHosts, "example.com/images",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "allowlist host with whitespace",
|
||||
yaml: signingKeyLine + "allowlist_hosts:\n - \"exa mple.com\"\n",
|
||||
wantErrSubstrings: []string{keyAllowlistHosts, "exa mple.com"},
|
||||
},
|
||||
{
|
||||
name: "allowlist entry not a string",
|
||||
yaml: signingKeyLine + "allowlist_hosts:\n - 123\n",
|
||||
wantErrSubstrings: []string{keyAllowlistHosts, "123"},
|
||||
},
|
||||
{
|
||||
name: "allowlist not a list",
|
||||
yaml: signingKeyLine + "allowlist_hosts:\n key: value\n",
|
||||
wantErrSubstrings: []string{keyAllowlistHosts},
|
||||
},
|
||||
{
|
||||
name: "signing_key too short",
|
||||
yaml: "signing_key: short\n",
|
||||
wantErrSubstrings: []string{keySigningKey},
|
||||
},
|
||||
{
|
||||
name: "signing_key missing",
|
||||
yaml: "port: 8080\n",
|
||||
wantErrSubstrings: []string{keySigningKey},
|
||||
},
|
||||
{
|
||||
name: "state_dir explicitly empty",
|
||||
yaml: signingKeyLine + "state_dir: \"\"\n",
|
||||
wantErrSubstrings: []string{keyStateDir},
|
||||
},
|
||||
{
|
||||
name: "sentry_dsn not a URL",
|
||||
yaml: signingKeyLine + "sentry_dsn: \"not a url\"\n",
|
||||
wantErrSubstrings: []string{keySentryDSN, "not a url"},
|
||||
},
|
||||
{
|
||||
name: "metrics username without password",
|
||||
yaml: signingKeyLine + "metrics:\n username: bob\n",
|
||||
wantErrSubstrings: []string{keyMetrics},
|
||||
},
|
||||
{
|
||||
name: "metrics password without username",
|
||||
yaml: signingKeyLine + "metrics:\n password: hunter2\n",
|
||||
wantErrSubstrings: []string{keyMetrics},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,140 +336,84 @@ allowlist_hosts: "s3.sneak.cloud, sneak.berlin"
|
||||
// value must produce a startup error naming the offending key, never
|
||||
// silently fall back to the default.
|
||||
func TestSetButInvalidValueAbortsStartup(t *testing.T) {
|
||||
signingKeyLine := "signing_key: " + validTestSigningKey + "\n"
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
yaml string
|
||||
// wantErrSubstrings must all appear in the error message.
|
||||
wantErrSubstrings []string
|
||||
}{
|
||||
runAbortCases(t, append(
|
||||
invalidScalarValueCases(), invalidHostAndCredentialCases()...))
|
||||
}
|
||||
|
||||
// explicitNullValueCases are configs where a key is explicitly set to
|
||||
// null (including the bare "key:" form and the "~" alias); each must
|
||||
// abort startup naming the key.
|
||||
func explicitNullValueCases() []abortCase {
|
||||
return []abortCase{
|
||||
{
|
||||
name: "port not a number",
|
||||
yaml: signingKeyLine + "port: banana\n",
|
||||
wantErrSubstrings: []string{"port", "banana"},
|
||||
name: "port explicit null",
|
||||
yaml: signingKeyLine + "port: null\n",
|
||||
wantErrSubstrings: []string{keyPort, nullValueText},
|
||||
},
|
||||
{
|
||||
name: "port zero",
|
||||
yaml: signingKeyLine + "port: 0\n",
|
||||
wantErrSubstrings: []string{"port", "0"},
|
||||
name: "port bare key no value",
|
||||
yaml: signingKeyLine + "port:\n",
|
||||
wantErrSubstrings: []string{keyPort, nullValueText},
|
||||
},
|
||||
{
|
||||
name: "port above 65535",
|
||||
yaml: signingKeyLine + "port: 99999\n",
|
||||
wantErrSubstrings: []string{"port", "99999"},
|
||||
name: "debug tilde null",
|
||||
yaml: signingKeyLine + "debug: ~\n",
|
||||
wantErrSubstrings: []string{keyDebug, nullValueText},
|
||||
},
|
||||
{
|
||||
name: "port fractional",
|
||||
yaml: signingKeyLine + "port: 8080.5\n",
|
||||
wantErrSubstrings: []string{"port", "8080.5"},
|
||||
name: "maintenance_mode null",
|
||||
yaml: signingKeyLine + "maintenance_mode: null\n",
|
||||
wantErrSubstrings: []string{keyMaintenanceMode, nullValueText},
|
||||
},
|
||||
{
|
||||
name: "debug not a bool",
|
||||
yaml: signingKeyLine + "debug: notabool\n",
|
||||
wantErrSubstrings: []string{"debug", "notabool"},
|
||||
name: "allow_http null",
|
||||
yaml: signingKeyLine + "allow_http: null\n",
|
||||
wantErrSubstrings: []string{keyAllowHTTP, nullValueText},
|
||||
},
|
||||
{
|
||||
name: "maintenance_mode not a bool",
|
||||
yaml: signingKeyLine + "maintenance_mode: sometimes\n",
|
||||
wantErrSubstrings: []string{"maintenance_mode", "sometimes"},
|
||||
name: "state_dir null",
|
||||
yaml: signingKeyLine + "state_dir: null\n",
|
||||
wantErrSubstrings: []string{keyStateDir, nullValueText},
|
||||
},
|
||||
{
|
||||
name: "allow_http numeric",
|
||||
yaml: signingKeyLine + "allow_http: 2\n",
|
||||
wantErrSubstrings: []string{"allow_http", "2"},
|
||||
name: "db_url null",
|
||||
yaml: signingKeyLine + "db_url: null\n",
|
||||
wantErrSubstrings: []string{keyDBURL, nullValueText},
|
||||
},
|
||||
{
|
||||
name: "upstream_connections_per_host zero",
|
||||
yaml: signingKeyLine + "upstream_connections_per_host: 0\n",
|
||||
wantErrSubstrings: []string{"upstream_connections_per_host", "0"},
|
||||
name: "sentry_dsn null",
|
||||
yaml: signingKeyLine + "sentry_dsn: null\n",
|
||||
wantErrSubstrings: []string{keySentryDSN, nullValueText},
|
||||
},
|
||||
{
|
||||
name: "upstream_connections_per_host negative",
|
||||
yaml: signingKeyLine + "upstream_connections_per_host: -3\n",
|
||||
wantErrSubstrings: []string{"upstream_connections_per_host", "-3"},
|
||||
name: "upstream_connections_per_host null",
|
||||
yaml: signingKeyLine + "upstream_connections_per_host: null\n",
|
||||
wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, nullValueText},
|
||||
},
|
||||
{
|
||||
name: "upstream_connections_per_host not a number",
|
||||
yaml: signingKeyLine + "upstream_connections_per_host: many\n",
|
||||
wantErrSubstrings: []string{"upstream_connections_per_host", "many"},
|
||||
name: "allowlist_hosts null",
|
||||
yaml: signingKeyLine + "allowlist_hosts: null\n",
|
||||
wantErrSubstrings: []string{keyAllowlistHosts, nullValueText},
|
||||
},
|
||||
{
|
||||
name: "allowlist host with scheme",
|
||||
yaml: signingKeyLine + "allowlist_hosts:\n - https://example.com\n",
|
||||
name: "signing_key null",
|
||||
yaml: "signing_key: null\n",
|
||||
wantErrSubstrings: []string{keySigningKey, nullValueText},
|
||||
},
|
||||
{
|
||||
name: "metrics null",
|
||||
yaml: signingKeyLine + "metrics: null\n",
|
||||
wantErrSubstrings: []string{keyMetrics, nullValueText},
|
||||
},
|
||||
{
|
||||
name: "metrics subkeys null",
|
||||
yaml: signingKeyLine + "metrics:\n username: null\n password: null\n",
|
||||
wantErrSubstrings: []string{
|
||||
"allowlist_hosts", "https://example.com",
|
||||
keyMetricsUsername, keyMetricsPassword, nullValueText,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "allowlist host with path",
|
||||
yaml: signingKeyLine + "allowlist_hosts:\n - example.com/images\n",
|
||||
wantErrSubstrings: []string{
|
||||
"allowlist_hosts", "example.com/images",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "allowlist host with whitespace",
|
||||
yaml: signingKeyLine + "allowlist_hosts:\n - \"exa mple.com\"\n",
|
||||
wantErrSubstrings: []string{"allowlist_hosts", "exa mple.com"},
|
||||
},
|
||||
{
|
||||
name: "allowlist entry not a string",
|
||||
yaml: signingKeyLine + "allowlist_hosts:\n - 123\n",
|
||||
wantErrSubstrings: []string{"allowlist_hosts", "123"},
|
||||
},
|
||||
{
|
||||
name: "allowlist not a list",
|
||||
yaml: signingKeyLine + "allowlist_hosts:\n key: value\n",
|
||||
wantErrSubstrings: []string{"allowlist_hosts"},
|
||||
},
|
||||
{
|
||||
name: "signing_key too short",
|
||||
yaml: "signing_key: short\n",
|
||||
wantErrSubstrings: []string{"signing_key"},
|
||||
},
|
||||
{
|
||||
name: "signing_key missing",
|
||||
yaml: "port: 8080\n",
|
||||
wantErrSubstrings: []string{"signing_key"},
|
||||
},
|
||||
{
|
||||
name: "state_dir explicitly empty",
|
||||
yaml: signingKeyLine + "state_dir: \"\"\n",
|
||||
wantErrSubstrings: []string{"state_dir"},
|
||||
},
|
||||
{
|
||||
name: "sentry_dsn not a URL",
|
||||
yaml: signingKeyLine + "sentry_dsn: \"not a url\"\n",
|
||||
wantErrSubstrings: []string{"sentry_dsn", "not a url"},
|
||||
},
|
||||
{
|
||||
name: "metrics username without password",
|
||||
yaml: signingKeyLine + "metrics:\n username: bob\n",
|
||||
wantErrSubstrings: []string{"metrics"},
|
||||
},
|
||||
{
|
||||
name: "metrics password without username",
|
||||
yaml: signingKeyLine + "metrics:\n password: hunter2\n",
|
||||
wantErrSubstrings: []string{"metrics"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c, err := configFromYAML(t, tc.yaml)
|
||||
if err == nil {
|
||||
t.Fatalf("config with %s must abort startup, got config: %+v", tc.name, c)
|
||||
}
|
||||
|
||||
t.Logf("got expected error: %v", err)
|
||||
|
||||
for _, want := range tc.wantErrSubstrings {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error %q does not mention %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,99 +422,9 @@ func TestSetButInvalidValueAbortsStartup(t *testing.T) {
|
||||
// startup naming the key. An explicit null is a SET value: it must
|
||||
// never silently fall back to the default the way an omitted key does.
|
||||
func TestExplicitNullValueAbortsStartup(t *testing.T) {
|
||||
signingKeyLine := "signing_key: " + validTestSigningKey + "\n"
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
yaml string
|
||||
// wantErrSubstrings must all appear in the error message.
|
||||
wantErrSubstrings []string
|
||||
}{
|
||||
{
|
||||
name: "port explicit null",
|
||||
yaml: signingKeyLine + "port: null\n",
|
||||
wantErrSubstrings: []string{"port", "null"},
|
||||
},
|
||||
{
|
||||
name: "port bare key no value",
|
||||
yaml: signingKeyLine + "port:\n",
|
||||
wantErrSubstrings: []string{"port", "null"},
|
||||
},
|
||||
{
|
||||
name: "debug tilde null",
|
||||
yaml: signingKeyLine + "debug: ~\n",
|
||||
wantErrSubstrings: []string{"debug", "null"},
|
||||
},
|
||||
{
|
||||
name: "maintenance_mode null",
|
||||
yaml: signingKeyLine + "maintenance_mode: null\n",
|
||||
wantErrSubstrings: []string{"maintenance_mode", "null"},
|
||||
},
|
||||
{
|
||||
name: "allow_http null",
|
||||
yaml: signingKeyLine + "allow_http: null\n",
|
||||
wantErrSubstrings: []string{"allow_http", "null"},
|
||||
},
|
||||
{
|
||||
name: "state_dir null",
|
||||
yaml: signingKeyLine + "state_dir: null\n",
|
||||
wantErrSubstrings: []string{"state_dir", "null"},
|
||||
},
|
||||
{
|
||||
name: "db_url null",
|
||||
yaml: signingKeyLine + "db_url: null\n",
|
||||
wantErrSubstrings: []string{"db_url", "null"},
|
||||
},
|
||||
{
|
||||
name: "sentry_dsn null",
|
||||
yaml: signingKeyLine + "sentry_dsn: null\n",
|
||||
wantErrSubstrings: []string{"sentry_dsn", "null"},
|
||||
},
|
||||
{
|
||||
name: "upstream_connections_per_host null",
|
||||
yaml: signingKeyLine + "upstream_connections_per_host: null\n",
|
||||
wantErrSubstrings: []string{"upstream_connections_per_host", "null"},
|
||||
},
|
||||
{
|
||||
name: "allowlist_hosts null",
|
||||
yaml: signingKeyLine + "allowlist_hosts: null\n",
|
||||
wantErrSubstrings: []string{"allowlist_hosts", "null"},
|
||||
},
|
||||
{
|
||||
name: "signing_key null",
|
||||
yaml: "signing_key: null\n",
|
||||
wantErrSubstrings: []string{"signing_key", "null"},
|
||||
},
|
||||
{
|
||||
name: "metrics null",
|
||||
yaml: signingKeyLine + "metrics: null\n",
|
||||
wantErrSubstrings: []string{"metrics", "null"},
|
||||
},
|
||||
{
|
||||
name: "metrics subkeys null",
|
||||
yaml: signingKeyLine + "metrics:\n username: null\n password: null\n",
|
||||
wantErrSubstrings: []string{
|
||||
"metrics.username", "metrics.password", "null",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c, err := configFromYAML(t, tc.yaml)
|
||||
if err == nil {
|
||||
t.Fatalf("config with %s must abort startup, got config: %+v", tc.name, c)
|
||||
}
|
||||
|
||||
t.Logf("got expected error: %v", err)
|
||||
|
||||
for _, want := range tc.wantErrSubstrings {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error %q does not mention %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
runAbortCases(t, explicitNullValueCases())
|
||||
}
|
||||
|
||||
// TestExplicitlyEmptyDBURLAbortsStartup verifies that db_url set to an
|
||||
@@ -400,7 +432,9 @@ func TestExplicitNullValueAbortsStartup(t *testing.T) {
|
||||
// a default, and defaults apply only to omitted keys. This matches
|
||||
// state_dir, where an explicitly empty value already aborts.
|
||||
func TestExplicitlyEmptyDBURLAbortsStartup(t *testing.T) {
|
||||
yamlContent := "signing_key: " + validTestSigningKey + "\ndb_url: \"\"\n"
|
||||
t.Parallel()
|
||||
|
||||
yamlContent := signingKeyLine + "db_url: \"\"\n"
|
||||
|
||||
c, err := configFromYAML(t, yamlContent)
|
||||
if err == nil {
|
||||
@@ -409,7 +443,7 @@ func TestExplicitlyEmptyDBURLAbortsStartup(t *testing.T) {
|
||||
|
||||
t.Logf("got expected error: %v", err)
|
||||
|
||||
if !strings.Contains(err.Error(), "db_url") {
|
||||
if !strings.Contains(err.Error(), keyDBURL) {
|
||||
t.Errorf("error %q does not name the offending key db_url", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -420,10 +454,12 @@ func TestExplicitlyEmptyDBURLAbortsStartup(t *testing.T) {
|
||||
// host written in FQDN trailing-dot form (e.g. evil.com.) and
|
||||
// effectively disable URL signing with a single character.
|
||||
func TestAllowlistHostsRejectsDotOnlyEntries(t *testing.T) {
|
||||
signingKeyLine := "signing_key: " + validTestSigningKey + "\n"
|
||||
t.Parallel()
|
||||
|
||||
for _, entry := range []string{".", ".."} {
|
||||
t.Run(entry, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
yamlContent := signingKeyLine +
|
||||
"allowlist_hosts:\n - \"" + entry + "\"\n"
|
||||
|
||||
@@ -435,7 +471,7 @@ func TestAllowlistHostsRejectsDotOnlyEntries(t *testing.T) {
|
||||
|
||||
t.Logf("got expected error: %v", err)
|
||||
|
||||
if !strings.Contains(err.Error(), "allowlist_hosts") {
|
||||
if !strings.Contains(err.Error(), keyAllowlistHosts) {
|
||||
t.Errorf("error %q does not name the offending key allowlist_hosts",
|
||||
err.Error())
|
||||
}
|
||||
@@ -444,8 +480,9 @@ func TestAllowlistHostsRejectsDotOnlyEntries(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUnknownTopLevelKeyAbortsStartup(t *testing.T) {
|
||||
yamlContent := `signing_key: ` + validTestSigningKey + `
|
||||
whitelist_hosts:
|
||||
t.Parallel()
|
||||
|
||||
yamlContent := signingKeyLine + `whitelist_hosts:
|
||||
- example.com
|
||||
`
|
||||
|
||||
@@ -462,8 +499,9 @@ whitelist_hosts:
|
||||
}
|
||||
|
||||
func TestUnknownMetricsSubkeyAbortsStartup(t *testing.T) {
|
||||
yamlContent := `signing_key: ` + validTestSigningKey + `
|
||||
metrics:
|
||||
t.Parallel()
|
||||
|
||||
yamlContent := signingKeyLine + `metrics:
|
||||
username: bob
|
||||
password: hunter2
|
||||
port: 9100
|
||||
@@ -482,13 +520,17 @@ metrics:
|
||||
}
|
||||
|
||||
func TestEnvSectionIsPermitted(t *testing.T) {
|
||||
yamlContent := `signing_key: ` + validTestSigningKey + `
|
||||
env:
|
||||
t.Parallel()
|
||||
|
||||
yamlContent := signingKeyLine + `env:
|
||||
PIXA_TEST_ENV_INJECTION: injected
|
||||
`
|
||||
|
||||
if _, err := configFromYAML(t, yamlContent); err != nil {
|
||||
t.Fatalf("env section must be permitted (smartconfig consumes it), got error: %v", err)
|
||||
_, err := configFromYAML(t, yamlContent)
|
||||
if err != nil {
|
||||
t.Fatalf(
|
||||
"env section must be permitted (smartconfig consumes it), got error: %v",
|
||||
err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,7 +538,8 @@ func TestMalformedConfigFileAbortsStartup(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yml")
|
||||
|
||||
if err := os.WriteFile(configPath, []byte("port: [unclosed\n"), 0o600); err != nil {
|
||||
err := os.WriteFile(configPath, []byte("port: [unclosed\n"), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write malformed config: %v", err)
|
||||
}
|
||||
|
||||
@@ -505,7 +548,7 @@ func TestMalformedConfigFileAbortsStartup(t *testing.T) {
|
||||
t.Setenv("PIXA_CONFIG_PATH", "")
|
||||
t.Chdir(tmpDir)
|
||||
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
log := slog.New(slog.DiscardHandler)
|
||||
|
||||
sc, err := loadConfigFile(log, "pixa-test-nonexistent-app")
|
||||
if err == nil {
|
||||
@@ -516,10 +559,14 @@ func TestMalformedConfigFileAbortsStartup(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEnsureStateDirCreatesDirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stateDir := filepath.Join(t.TempDir(), "nested", "state")
|
||||
|
||||
c := &Config{StateDir: stateDir}
|
||||
if err := c.ensureStateDirWritable(); err != nil {
|
||||
|
||||
err := c.ensureStateDirWritable()
|
||||
if err != nil {
|
||||
t.Fatalf("creatable state_dir must validate, got error: %v", err)
|
||||
}
|
||||
|
||||
@@ -530,6 +577,8 @@ func TestEnsureStateDirCreatesDirectory(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEnsureStateDirFailsOnUncreatablePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A path below /dev/null can never be created, even when running
|
||||
// as root (as in the Docker build).
|
||||
c := &Config{StateDir: "/dev/null/pixa-state"}
|
||||
@@ -541,7 +590,7 @@ func TestEnsureStateDirFailsOnUncreatablePath(t *testing.T) {
|
||||
|
||||
t.Logf("got expected error: %v", err)
|
||||
|
||||
if !strings.Contains(err.Error(), "state_dir") {
|
||||
if !strings.Contains(err.Error(), keyStateDir) {
|
||||
t.Errorf("error %q does not name the offending key state_dir", err.Error())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user