package config_test import ( "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" ) 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 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, }, } 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 { expectStartupError(t) } 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, }, } 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, ) }) } } 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) }