package config_test import ( "bytes" "log/slog" "os" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/fx" "go.uber.org/fx/fxtest" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/logger" ) // Shared subtest names for the env-parsing tables below, which all // exercise the same three cases against different variables. const ( caseUnsetUsesDefault = "unset uses default" caseValidValueParsed = "valid value is parsed" caseUnparseableFails = "unparseable value fails startup" // cidrPrivateV4 is the sample trusted-proxy block the // TRUSTED_PROXIES cases are built from. cidrPrivateV4 = "10.0.0.0/8" // metricsAuthValue is the sample METRICS_PASSWORD the metrics // credential cases are built from. It is asserted absent from // the startup error, so it must not be a substring of either // variable name that error prints. metricsAuthValue = "s3cret" ) func TestEnvironmentConfig(t *testing.T) { tests := []struct { name string envValue string envVars map[string]string expectError bool isDev bool isProd bool }{ { name: "default is dev", isDev: true, isProd: false, }, { name: "explicit dev", envValue: "dev", isDev: true, isProd: false, }, { name: "explicit prod", envValue: "prod", isDev: false, isProd: true, }, { name: "invalid environment", envValue: "staging", expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. if tt.envValue != "" { t.Setenv( "WEBHOOKER_ENVIRONMENT", tt.envValue, ) } else { require.NoError(t, os.Unsetenv( "WEBHOOKER_ENVIRONMENT", )) } for k, v := range tt.envVars { t.Setenv(k, v) } if tt.expectError { testEnvironmentConfigError(t) } else { testEnvironmentConfigSuccess( t, tt.isDev, tt.isProd, ) } }) } } func testEnvironmentConfigError(t *testing.T) { t.Helper() var cfg *config.Config app := fx.New( fx.NopLogger, fx.Provide( globals.New, logger.New, config.New, ), fx.Populate(&cfg), ) assert.Error(t, app.Err()) } func testEnvironmentConfigSuccess( t *testing.T, isDev, isProd bool, ) { t.Helper() var cfg *config.Config app := fxtest.New( t, fx.Provide( globals.New, logger.New, config.New, ), fx.Populate(&cfg), ) require.NoError(t, app.Err()) app.RequireStart() defer app.RequireStop() assert.Equal(t, isDev, cfg.IsDev()) assert.Equal(t, isProd, cfg.IsProd()) } func TestRetentionSweepInterval(t *testing.T) { tests := []struct { name string set bool value string expectError bool // sentinel, when set, must be wrapped by the startup // error; every error case must additionally name the // variable in its message. sentinel error expected time.Duration }{ { name: caseUnsetUsesDefault, set: false, expected: time.Hour, }, { name: caseValidValueParsed, set: true, value: "15m", expected: 15 * time.Minute, }, { name: caseUnparseableFails, set: true, value: "not-a-duration", expectError: true, }, { // A non-positive period panics the ticker in the // reaper and archive-sweeper goroutines, long after // startup has reported success, so it has to fail // here instead. name: "zero fails startup", set: true, value: "0s", expectError: true, sentinel: config.ErrNonPositiveValue, }, { name: "negative fails startup", set: true, value: "-1h", expectError: true, sentinel: config.ErrNonPositiveValue, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") if tt.set { t.Setenv("RETENTION_SWEEP_INTERVAL", tt.value) } else { require.NoError(t, os.Unsetenv( "RETENTION_SWEEP_INTERVAL", )) } if tt.expectError { expectStartupErrorFor( t, "RETENTION_SWEEP_INTERVAL", tt.sentinel, ) } else { testRetentionSweepIntervalSuccess(t, tt.expected) } }) } } // startupError builds the app config.New belongs to and returns // the error fx reports, which is non-nil whenever an environment // value is set but invalid. func startupError(t *testing.T) error { t.Helper() var cfg *config.Config app := fx.New( fx.NopLogger, fx.Provide( globals.New, logger.New, config.New, ), fx.Populate(&cfg), ) return app.Err() } // expectStartupError asserts that fx refuses to build the app, // which is what a set-but-invalid environment value must cause. func expectStartupError(t *testing.T) { t.Helper() assert.Error(t, startupError(t)) } // expectStartupErrorFor asserts that startup fails, that the error // names the offending variable so an operator can find it, and, // when sentinel is non-nil, that it wraps that sentinel. func expectStartupErrorFor( t *testing.T, key string, sentinel error, ) { t.Helper() err := startupError(t) require.ErrorContains(t, err, key) if sentinel != nil { require.ErrorIs(t, err, sentinel) } } func testRetentionSweepIntervalSuccess( t *testing.T, expected time.Duration, ) { t.Helper() var cfg *config.Config app := fxtest.New( t, fx.Provide( globals.New, logger.New, config.New, ), fx.Populate(&cfg), ) require.NoError(t, app.Err()) app.RequireStart() defer app.RequireStop() assert.Equal(t, expected, cfg.RetentionSweepInterval) } func TestSessionIdleTimeout(t *testing.T) { tests := []struct { name string set bool value string expectError bool expected time.Duration }{ { name: caseUnsetUsesDefault, set: false, expected: 24 * time.Hour, }, { name: caseValidValueParsed, set: true, value: "30m", expected: 30 * time.Minute, }, { name: caseUnparseableFails, set: true, value: "not-a-duration", expectError: true, }, { // Non-positive is "idle expiry disabled" for this // variable, not a configuration error: unlike // RETENTION_SWEEP_INTERVAL it never becomes a ticker // period. name: "zero disables idle expiry", set: true, value: "0s", expected: 0, }, { name: "negative disables idle expiry", set: true, value: "-1h", expected: -time.Hour, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") if tt.set { t.Setenv("SESSION_IDLE_TIMEOUT", tt.value) } else { require.NoError(t, os.Unsetenv( "SESSION_IDLE_TIMEOUT", )) } if tt.expectError { expectStartupError(t) } else { testSessionIdleTimeoutSuccess(t, tt.expected) } }) } } func testSessionIdleTimeoutSuccess( t *testing.T, expected time.Duration, ) { t.Helper() var cfg *config.Config app := fxtest.New( t, fx.Provide( globals.New, logger.New, config.New, ), fx.Populate(&cfg), ) require.NoError(t, app.Err()) app.RequireStart() defer app.RequireStop() assert.Equal(t, expected, cfg.SessionIdleTimeout) } func TestDefaultDataDir(t *testing.T) { for _, env := range []string{"", "dev", "prod"} { name := env if name == "" { name = "unset" } t.Run("env="+name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. if env != "" { t.Setenv("WEBHOOKER_ENVIRONMENT", env) } else { require.NoError(t, os.Unsetenv( "WEBHOOKER_ENVIRONMENT", )) } require.NoError(t, os.Unsetenv("DATA_DIR")) var cfg *config.Config app := fxtest.New( t, fx.Provide( globals.New, logger.New, config.New, ), fx.Populate(&cfg), ) require.NoError(t, app.Err()) app.RequireStart() defer app.RequireStop() assert.Equal( t, "/var/lib/webhooker", cfg.DataDir, ) }) } } // TestDataDirHelper pins the exported resolver against the value // Config carries. The entry point takes the DATA_DIR lock through the // helper before the fx graph exists, so the two disagreeing would mean // locking one directory and writing to another. func TestDataDirHelper(t *testing.T) { for _, set := range []string{"", "/tmp/webhooker-datadir-helper"} { name := "set" if set == "" { name = "unset" } t.Run(name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. if set == "" { require.NoError(t, os.Unsetenv("DATA_DIR")) } else { t.Setenv("DATA_DIR", set) } expected := set if expected == "" { expected = config.DefaultDataDir } assert.Equal(t, expected, config.DataDir()) }) } } func TestReceiverRateLimit(t *testing.T) { tests := []struct { name string set bool value string expectError bool // sentinel, when set, must be wrapped by the startup // error; every error case must additionally name the // variable in its message. sentinel error expected int }{ { name: caseUnsetUsesDefault, set: false, expected: 120, }, { name: caseValidValueParsed, set: true, value: "30", expected: 30, }, { name: caseUnparseableFails, set: true, value: "not-a-number", expectError: true, }, { name: "zero fails startup", set: true, value: "0", expectError: true, sentinel: config.ErrNonPositiveValue, }, { name: "negative fails startup", set: true, value: "-5", expectError: true, sentinel: config.ErrNonPositiveValue, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") if tt.set { t.Setenv("RECEIVER_RATE_LIMIT", tt.value) } else { require.NoError(t, os.Unsetenv( "RECEIVER_RATE_LIMIT", )) } if tt.expectError { expectStartupErrorFor( t, "RECEIVER_RATE_LIMIT", tt.sentinel, ) } else { testReceiverRateLimitSuccess(t, tt.expected) } }) } } func testReceiverRateLimitSuccess( t *testing.T, expected int, ) { t.Helper() var cfg *config.Config app := fxtest.New( t, fx.Provide( globals.New, logger.New, config.New, ), fx.Populate(&cfg), ) require.NoError(t, app.Err()) app.RequireStart() defer app.RequireStop() assert.Equal(t, expected, cfg.ReceiverRateLimit) } func TestTrustedProxies(t *testing.T) { tests := []struct { name string set bool value string expectError bool expected []string }{ { // The default must be "trust nobody": an empty list // means forwarded headers are ignored, never that // every peer may speak for the client. name: caseUnsetUsesDefault, set: false, expected: []string{}, }, { name: "blank value trusts nothing", set: true, value: " ", expected: []string{}, }, { name: caseValidValueParsed, set: true, value: cidrPrivateV4 + ", 192.168.1.7 ,2001:db8::/32", expected: []string{ cidrPrivateV4, "192.168.1.7/32", "2001:db8::/32", }, }, { name: "host bits are masked off", set: true, value: "10.1.2.3/8", expected: []string{cidrPrivateV4}, }, { // Peer addresses are unmapped before they are // matched, so an IPv4-mapped prefix kept in that // form could never match anything. name: "IPv4-mapped prefix is unmapped", set: true, value: "::ffff:10.0.0.0/104", expected: []string{cidrPrivateV4}, }, { name: caseUnparseableFails, set: true, value: cidrPrivateV4 + ",not-an-address", expectError: true, }, { name: "out-of-range prefix length fails startup", set: true, value: "10.0.0.0/33", expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") if tt.set { t.Setenv("TRUSTED_PROXIES", tt.value) } else { require.NoError(t, os.Unsetenv("TRUSTED_PROXIES")) } if tt.expectError { expectStartupErrorFor( t, "TRUSTED_PROXIES", config.ErrInvalidCIDR, ) } else { testTrustedProxiesSuccess(t, tt.expected) } }) } } func testTrustedProxiesSuccess( t *testing.T, expected []string, ) { t.Helper() var cfg *config.Config app := fxtest.New( t, fx.Provide( globals.New, logger.New, config.New, ), fx.Populate(&cfg), ) require.NoError(t, app.Err()) app.RequireStart() defer app.RequireStop() got := make([]string, 0, len(cfg.TrustedProxies)) for _, prefix := range cfg.TrustedProxies { got = append(got, prefix.String()) } assert.Equal(t, expected, got) } // TestAllowedEgressCIDRs covers ALLOWED_EGRESS_CIDRS, the escape // hatch that lets a self-hosted deployment forward to its own // network. Unset it must stay empty, so the SSRF guard keeps // refusing every private/reserved range; a set-but-unparseable // value must abort startup naming the variable rather than // silently running with a list the operator did not write. func TestAllowedEgressCIDRs(t *testing.T) { tests := []struct { name string set bool value string expected []string expectError bool }{ { name: caseUnsetUsesDefault, set: false, expected: []string{}, }, { name: "empty value yields empty list", set: true, value: "", expected: []string{}, }, { name: caseValidValueParsed, set: true, value: cidrPrivateV4, expected: []string{cidrPrivateV4}, }, { name: "multiple blocks with whitespace", set: true, value: " 10.0.0.0/8 , 127.0.0.0/8 ", expected: []string{cidrPrivateV4, "127.0.0.0/8"}, }, { name: "bare address becomes a single host", set: true, value: "172.17.0.5", expected: []string{"172.17.0.5/32"}, }, { name: caseUnparseableFails, set: true, value: cidrPrivateV4 + ",not-an-address", expectError: true, }, { name: "out-of-range prefix length fails startup", set: true, value: "10.0.0.0/33", expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") if tt.set { t.Setenv("ALLOWED_EGRESS_CIDRS", tt.value) } else { require.NoError( t, os.Unsetenv("ALLOWED_EGRESS_CIDRS"), ) } if tt.expectError { expectStartupErrorFor( t, "ALLOWED_EGRESS_CIDRS", config.ErrInvalidCIDR, ) } else { testAllowedEgressCIDRsSuccess(t, tt.expected) } }) } } func testAllowedEgressCIDRsSuccess( t *testing.T, expected []string, ) { t.Helper() var cfg *config.Config app := fxtest.New( t, fx.Provide( globals.New, logger.New, config.New, ), fx.Populate(&cfg), ) require.NoError(t, app.Err()) app.RequireStart() defer app.RequireStop() assert.Equal( t, expected, config.PrefixStrings(cfg.AllowedEgressCIDRs), ) } // TestEgressAllowlistWarning covers the startup log that shows an // operator the hole ALLOWED_EGRESS_CIDRS opened. It must stay // silent on the default (empty) list and, when set, print the // blocks themselves rather than a count. func TestEgressAllowlistWarning(t *testing.T) { tests := []struct { name string allowed string expectWarning bool }{ { name: "empty allowlist is quiet", expectWarning: false, }, { name: "non-empty allowlist warns", allowed: "10.0.0.0/8,127.0.0.0/8", expectWarning: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. t.Setenv("WEBHOOKER_ENVIRONMENT", config.EnvironmentDev) if tt.allowed == "" { require.NoError( t, os.Unsetenv("ALLOWED_EGRESS_CIDRS"), ) } else { t.Setenv("ALLOWED_EGRESS_CIDRS", tt.allowed) } var buf bytes.Buffer log := slog.New(slog.NewJSONHandler( &buf, &slog.HandlerOptions{ Level: slog.LevelDebug, }, )) require.NoError( t, config.WarnEgressAllowlistForTest(log), ) if !tt.expectWarning { assert.Empty(t, buf.String()) return } logged := buf.String() assert.Contains(t, logged, `"level":"WARN"`) assert.Contains(t, logged, "ALLOWED_EGRESS_CIDRS") // The blocks themselves, not a count: the operator has // to be able to read back which networks are open. assert.Contains(t, logged, "10.0.0.0/8") assert.Contains(t, logged, "127.0.0.0/8") // What stays shut. Asserted on the clause naming the // wider set rather than on "Link-local" alone, so the // string cannot narrow back to link-local only while // the always-blocked set covers ULA, CGNAT and two // public metadata addresses as well. assert.Contains(t, logged, "metadata endpoints outside it") }) } } // TestSharedRateLimitBucketWarning covers the startup warning that // tells an operator a deployment behind a reverse proxy shares one // rate-limit bucket between every client, which turns the receiver // limits into service-wide ceilings and collapses login failure // counting. It must fire whenever TRUSTED_PROXIES is empty, // in any environment: WEBHOOKER_ENVIRONMENT defaults to dev, so gating // on it would silence the warning for exactly the operator who never // configured the deployment. It stays quiet once proxies are named. func TestSharedRateLimitBucketWarning(t *testing.T) { tests := []struct { name string environment string trustedProxies string expectWarning bool }{ { name: "prod without trusted proxies warns", environment: config.EnvironmentProd, expectWarning: true, }, { name: "prod with trusted proxies is quiet", environment: config.EnvironmentProd, trustedProxies: cidrPrivateV4, expectWarning: false, }, { // The default environment. An internet-exposed // deployment whose operator never set // WEBHOOKER_ENVIRONMENT lands here and has exactly // the exposure the warning announces. name: "dev without trusted proxies warns", environment: config.EnvironmentDev, expectWarning: true, }, { name: "dev with trusted proxies is quiet", environment: config.EnvironmentDev, trustedProxies: cidrPrivateV4, expectWarning: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. t.Setenv("WEBHOOKER_ENVIRONMENT", tt.environment) if tt.trustedProxies == "" { require.NoError( t, os.Unsetenv("TRUSTED_PROXIES"), ) } else { t.Setenv("TRUSTED_PROXIES", tt.trustedProxies) } var buf bytes.Buffer log := slog.New(slog.NewJSONHandler( &buf, &slog.HandlerOptions{ Level: slog.LevelDebug, }, )) require.NoError( t, config.WarnSharedRateLimitBucketForTest(log), ) if !tt.expectWarning { assert.Empty(t, buf.String()) return } logged := buf.String() assert.Contains(t, logged, `"level":"WARN"`) assert.Contains(t, logged, "TRUSTED_PROXIES") assert.Contains(t, logged, "share one bucket") assert.Contains( t, logged, "throttle every other client's failed logins", ) // The warning must not claim a lockout the login // endpoint no longer permits: credentials are verified // before any budget is spent. assert.Contains( t, logged, "a correct password still gets in", ) // The text must stay accurate for a developer with // nothing in front of the process, where an empty // list costs nothing. assert.Contains( t, logged, "nothing proxying to this process", ) }) } } // metricsEnv describes what one subtest below puts in the // environment for a single METRICS_ variable. A variable that is // set to the empty string and one that is not set at all are // distinct inputs here, because the reported bug arrived through // the first of them. type metricsEnv struct { set bool value string } // unset leaves the variable out of the environment entirely. func unset() metricsEnv { return metricsEnv{set: false, value: ""} } // setTo sets the variable, including to the empty string. func setTo(value string) metricsEnv { return metricsEnv{set: true, value: value} } // metricsAuthCase is one row of the table in TestMetricsAuthConfig, // named so the table can live in its own function and keep the test // itself short. type metricsAuthCase struct { name string username metricsEnv password metricsEnv expectError bool expectAuth bool } // metricsAuthCases enumerates every combination of the two // credentials, counting "set to the empty string" and "not set at // all" as separate inputs on each side. func metricsAuthCases() []metricsAuthCase { return []metricsAuthCase{ { name: "both unset leaves metrics unmounted", username: unset(), password: unset(), }, { name: "both empty leaves metrics unmounted", username: setTo(""), password: setTo(""), }, { name: "both set enables metrics auth", username: setTo("metrics"), password: setTo(metricsAuthValue), expectAuth: true, }, { name: "username with unset password fails", username: setTo("metrics"), password: unset(), expectError: true, }, { name: "username with empty password fails", username: setTo("metrics"), password: setTo(""), expectError: true, }, { name: "password with unset username fails", username: unset(), password: setTo(metricsAuthValue), expectError: true, }, { name: "password with empty username fails", username: setTo(""), password: setTo(metricsAuthValue), expectError: true, }, } } // TestMetricsAuthConfig covers every combination of METRICS_USERNAME // and METRICS_PASSWORD. Either both carry a value, in which case // /metrics is served behind basic auth, or neither does, in which // case the route is never mounted. One without the other is a // startup error rather than a fallback: mounting on the username // alone published /metrics behind a credential map that accepted an // empty password, which is the defect this test exists to pin. See // https://git.eeqj.de/sneak/webhooker/issues/205. func TestMetricsAuthConfig(t *testing.T) { for _, tt := range metricsAuthCases() { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. if tt.username.set { t.Setenv("METRICS_USERNAME", tt.username.value) } else { require.NoError( t, os.Unsetenv("METRICS_USERNAME"), ) } if tt.password.set { t.Setenv("METRICS_PASSWORD", tt.password.value) } else { require.NoError( t, os.Unsetenv("METRICS_PASSWORD"), ) } if tt.expectError { assertMetricsAuthRejected(t) return } assertMetricsAuthAccepted(t, tt.expectAuth) }) } } // assertMetricsAuthRejected requires that fx refused to build the // graph, that the failure is ErrIncompleteMetricsAuth, and that the // operator is told both variable names — the point of failing here // rather than degrading is that the message says what to fix. func assertMetricsAuthRejected(t *testing.T) { t.Helper() var cfg *config.Config app := fx.New( fx.NopLogger, fx.Provide(globals.New, logger.New, config.New), fx.Populate(&cfg), ) err := app.Err() require.Error(t, err) require.ErrorIs(t, err, config.ErrIncompleteMetricsAuth) assert.Contains(t, err.Error(), "METRICS_USERNAME") assert.Contains(t, err.Error(), "METRICS_PASSWORD") // The password is a secret and must not reach a startup error. assert.NotContains(t, err.Error(), metricsAuthValue) } // assertMetricsAuthAccepted requires that startup succeeded and that // MetricsAuthEnabled — the single value the /metrics mount and the // startup log both read — reports what the environment asked for. func assertMetricsAuthAccepted(t *testing.T, expectAuth bool) { t.Helper() var cfg *config.Config app := fxtest.New( t, fx.Provide(globals.New, logger.New, config.New), fx.Populate(&cfg), ) require.NoError(t, app.Err()) app.RequireStart() defer app.RequireStop() assert.Equal(t, expectAuth, cfg.MetricsAuthEnabled()) }