package config_test import ( "os" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/fx" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/logger" ) // testEnvKey is a throwaway variable name used only by the helper // tables below, so they cannot disturb real configuration. const testEnvKey = "WEBHOOKER_TEST_VALUE" // Real configuration variables exercised by the config.New tests. const ( envKeyPort = "PORT" envKeyDebug = "DEBUG" envKeyMaintenanceMode = "MAINTENANCE_MODE" envKeyBindAddress = "BIND_ADDRESS" ) // Sample BIND_ADDRESS values used by the tables below. const ( // bindAddressDefault is the shipped default. It is asserted // against the package's own constant in // TestNewUsesDefaultsWhenUnset, so the two cannot drift. bindAddressDefault = "127.0.0.1" // bindAddressWildcard is the value a container deployment sets. bindAddressWildcard = "0.0.0.0" // bindAddressSample is an arbitrary specific address, standing // for "one interface of several". bindAddressSample = "10.1.2.3" ) // envBoolCase is one row of the envBool table. type envBoolCase struct { name string set bool value string defaultValue bool expectError bool expected bool } // envBoolCases is the envBool table, kept out of the test body so // the test itself stays readable. func envBoolCases() []envBoolCase { return []envBoolCase{ { name: "unset uses default false", defaultValue: false, expected: false, }, { name: "unset uses default true", defaultValue: true, expected: true, }, { name: "empty uses default true", set: true, value: "", defaultValue: true, expected: true, }, { name: "true is parsed", set: true, value: "true", expected: true, }, { name: "one is parsed", set: true, value: "1", expected: true, }, { name: "False is parsed", set: true, value: "False", defaultValue: true, expected: false, }, { name: "zero is parsed", set: true, value: "0", defaultValue: true, expected: false, }, { name: "yes is rejected", set: true, value: "yes", expectError: true, }, { name: "on is rejected", set: true, value: "on", expectError: true, }, { name: "typo is rejected", set: true, value: "ture", expectError: true, }, } } func TestEnvBool(t *testing.T) { for _, tt := range envBoolCases() { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. if tt.set { t.Setenv(testEnvKey, tt.value) } else { require.NoError(t, os.Unsetenv(testEnvKey)) } got, err := config.EnvBoolForTest( testEnvKey, tt.defaultValue, ) if tt.expectError { require.Error(t, err) assert.Contains(t, err.Error(), testEnvKey) assert.Contains(t, err.Error(), tt.value) return } require.NoError(t, err) assert.Equal(t, tt.expected, got) }) } } func TestEnvPositiveInt(t *testing.T) { const defaultValue = 7 tests := []struct { name string set bool value string expectError bool errIs error expected int }{ { name: "unset returns the default integer", expected: defaultValue, }, { name: "empty returns the default integer", set: true, value: "", expected: defaultValue, }, { name: "positive value is parsed", set: true, value: "42", expected: 42, }, { name: "unparseable value is rejected", set: true, value: "not-a-number", expectError: true, }, { name: "zero is rejected", set: true, value: "0", expectError: true, errIs: config.ErrNonPositiveValue, }, { name: "negative is rejected", set: true, value: "-5", expectError: true, errIs: 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. if tt.set { t.Setenv(testEnvKey, tt.value) } else { require.NoError(t, os.Unsetenv(testEnvKey)) } got, err := config.EnvPositiveIntForTest( testEnvKey, defaultValue, ) if tt.expectError { require.Error(t, err) assert.Contains(t, err.Error(), testEnvKey) assert.Contains(t, err.Error(), tt.value) if tt.errIs != nil { require.ErrorIs(t, err, tt.errIs) } return } require.NoError(t, err) assert.Equal(t, tt.expected, got) }) } } func TestEnvPort(t *testing.T) { const defaultValue = 8080 tests := []struct { name string set bool value string expectError bool errIs error expected int }{ { name: "unset returns the default port", expected: defaultValue, }, { name: "valid port is parsed", set: true, value: "9000", expected: 9000, }, { name: "highest port is accepted", set: true, value: "65535", expected: 65535, }, { name: "unparseable value is rejected", set: true, value: "not-a-port", expectError: true, }, { name: "zero is rejected", set: true, value: "0", expectError: true, errIs: config.ErrNonPositiveValue, }, { name: "above the port range is rejected", set: true, value: "65536", expectError: true, errIs: config.ErrInvalidPort, }, } 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.set { t.Setenv(testEnvKey, tt.value) } else { require.NoError(t, os.Unsetenv(testEnvKey)) } got, err := config.EnvPortForTest( testEnvKey, defaultValue, ) if tt.expectError { require.Error(t, err) assert.Contains(t, err.Error(), testEnvKey) if tt.errIs != nil { require.ErrorIs(t, err, tt.errIs) } return } require.NoError(t, err) assert.Equal(t, tt.expected, got) }) } } // TestEnvBindAddress covers BIND_ADDRESS parsing. // // Only IP address literals are accepted. Every rejection below is a // value an operator plausibly writes — a hostname, a host:port, a // CIDR block — and each has to abort startup rather than fall back to // the default, because falling back would bind an address other than // the one asked for and, in the wildcard-default case this setting // exists to end, publish cleartext on every interface. func TestEnvBindAddress(t *testing.T) { for _, tt := range envBindAddressCases() { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. if tt.set { t.Setenv(testEnvKey, tt.value) } else { require.NoError(t, os.Unsetenv(testEnvKey)) } got, err := config.EnvBindAddressForTest( testEnvKey, bindAddressDefault, ) if tt.expectError { require.Error(t, err) require.ErrorIs(t, err, config.ErrInvalidBindAddress) assert.Contains(t, err.Error(), testEnvKey) assert.Contains(t, err.Error(), tt.value) return } require.NoError(t, err) assert.Equal(t, tt.expected, got) }) } } // envBindAddressCase is one row of the envBindAddress table. type envBindAddressCase struct { name string set bool value string expectError bool expected string } // envBindAddressCases is the envBindAddress table, kept out of the // test body so the test itself stays readable. func envBindAddressCases() []envBindAddressCase { return append( envBindAddressAcceptedCases(), envBindAddressRejectedCases()..., ) } // envBindAddressAcceptedCases are the values that parse: the three // spellings of "unset" that take the default, and the literals. func envBindAddressAcceptedCases() []envBindAddressCase { return []envBindAddressCase{ { name: "unset returns the default", expected: bindAddressDefault, }, { name: "empty returns the default", set: true, value: "", expected: bindAddressDefault, }, { name: "whitespace returns the default", set: true, value: " ", expected: bindAddressDefault, }, { name: "ipv4 wildcard is parsed", set: true, value: bindAddressWildcard, expected: bindAddressWildcard, }, { name: "ipv4 literal is parsed", set: true, value: bindAddressSample, expected: bindAddressSample, }, { name: "surrounding whitespace is trimmed", set: true, value: " " + bindAddressSample + " ", expected: bindAddressSample, }, { name: "ipv6 wildcard is parsed", set: true, value: "::", expected: "::", }, { name: "ipv6 literal is parsed", set: true, value: "2001:db8::5", expected: "2001:db8::5", }, } } // envBindAddressRejectedCases are the values that abort startup. // Each is something an operator plausibly writes, and none may fall // back to the default: the default is loopback, so a silent fallback // would bind somewhere other than what was asked for. func envBindAddressRejectedCases() []envBindAddressCase { return []envBindAddressCase{ { name: "garbage is rejected", set: true, value: "not-an-address", expectError: true, }, { name: "hostname is rejected", set: true, value: "localhost", expectError: true, }, { name: "unresolvable hostname is rejected", set: true, value: "no-such-host.invalid", expectError: true, }, { name: "host and port is rejected", set: true, value: bindAddressDefault + ":8080", expectError: true, }, { name: "bracketed ipv6 is rejected", set: true, value: "[::1]", expectError: true, }, { name: "CIDR block is rejected", set: true, value: "10.0.0.0/8", expectError: true, }, } } // buildConfig constructs a Config through fx exactly as the // application does, returning the config and any construction error. func buildConfig(t *testing.T) (*config.Config, error) { t.Helper() var cfg *config.Config app := fx.New( fx.NopLogger, fx.Provide( globals.New, logger.New, config.New, ), fx.Populate(&cfg), ) return cfg, app.Err() } func TestNewRejectsBadEnvValues(t *testing.T) { for _, tt := range badEnvValueCases() { 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") t.Setenv(tt.key, tt.value) cfg, err := buildConfig(t) if tt.expectError { require.Error(t, err) assert.Contains(t, err.Error(), tt.key) assert.Contains(t, err.Error(), tt.value) return } require.NoError(t, err) require.NotNil(t, cfg) tt.check(t, cfg) }) } } // badEnvValueCase is one row of the config.New table: a variable, the // value it is set to, and either the assertion that startup fails // naming both, or a check on the Config that resulted. type badEnvValueCase struct { name string key string value string expectError bool check func(t *testing.T, cfg *config.Config) } // badEnvValueCases is the config.New table, kept out of the test body // so the test itself stays readable. It is assembled from per-variable // groups because one literal covering every variable outgrew the // function-length budget. func badEnvValueCases() []badEnvValueCase { cases := listenerEnvValueCases() cases = append(cases, flagEnvValueCases()...) cases = append(cases, sentryEnvValueCases()...) return cases } // listenerEnvValueCases covers the two variables that describe the // HTTP listener. func listenerEnvValueCases() []badEnvValueCase { return []badEnvValueCase{ { name: "valid PORT is used", key: envKeyPort, value: "9001", check: func(t *testing.T, cfg *config.Config) { t.Helper() assert.Equal(t, 9001, cfg.Port) }, }, { name: "unparseable PORT aborts startup", key: envKeyPort, value: "eighty-eighty", expectError: true, }, { name: "out-of-range PORT aborts startup", key: envKeyPort, value: "70000", expectError: true, }, { name: "valid BIND_ADDRESS is used", key: envKeyBindAddress, value: bindAddressWildcard, check: func(t *testing.T, cfg *config.Config) { t.Helper() assert.Equal( t, bindAddressWildcard, cfg.BindAddress, ) }, }, { name: "unparseable BIND_ADDRESS aborts startup", key: envKeyBindAddress, value: "not-an-address", expectError: true, }, { name: "hostname BIND_ADDRESS aborts startup", key: envKeyBindAddress, value: "localhost", expectError: true, }, { name: "BIND_ADDRESS with a port aborts startup", key: envKeyBindAddress, value: bindAddressDefault + ":8080", expectError: true, }, } } // flagEnvValueCases covers the boolean variables. func flagEnvValueCases() []badEnvValueCase { return []badEnvValueCase{ { name: "valid DEBUG is used", key: envKeyDebug, value: "true", check: func(t *testing.T, cfg *config.Config) { t.Helper() assert.True(t, cfg.Debug) }, }, { name: "unparseable DEBUG aborts startup", key: envKeyDebug, value: "ture", expectError: true, }, { name: "unparseable MAINTENANCE_MODE aborts startup", key: envKeyMaintenanceMode, value: "sometimes", expectError: true, }, } } // sentryEnvValueCases covers SENTRY_DSN. The three rejected values are // the ones measured on the defect: each initialised the SDK with an // error and left the process serving with error reporting off. func sentryEnvValueCases() []badEnvValueCase { return []badEnvValueCase{ { name: "valid SENTRY_DSN is used", key: envKeySentryDSN, value: validSentryDSN, check: func(t *testing.T, cfg *config.Config) { t.Helper() assert.Equal(t, validSentryDSN, cfg.SentryDSN) assert.True(t, cfg.SentryEnabled()) }, }, { name: "unparseable SENTRY_DSN aborts startup", key: envKeySentryDSN, value: "not-a-dsn", expectError: true, }, { name: "SENTRY_DSN that is not a URL aborts startup", key: envKeySentryDSN, value: "%%%", expectError: true, }, { name: "keyless SENTRY_DSN aborts startup", key: envKeySentryDSN, value: "https://example.invalid/1", expectError: true, }, } } // TestNewUsesDefaultsWhenUnset proves the fail-loud behaviour did not // break the legitimate unset case: absent variables still get their // documented defaults. func TestNewUsesDefaultsWhenUnset(t *testing.T) { t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") for _, key := range []string{ envKeyPort, envKeyDebug, envKeyMaintenanceMode, envKeyBindAddress, envKeySentryDSN, } { require.NoError(t, os.Unsetenv(key)) } cfg, err := buildConfig(t) require.NoError(t, err) require.NotNil(t, cfg) assert.Equal(t, 8080, cfg.Port) assert.False(t, cfg.Debug) assert.False(t, cfg.MaintenanceMode) // Loopback, not the wildcard: the default must not publish the // cleartext admin UI and the unauthenticated receiver on every // interface of a host that configured nothing. The value is read // from the package rather than repeated, so the README's // documented default and the compiled-in one are pinned to the // same constant. assert.Equal( t, config.DefaultBindAddressForTest, cfg.BindAddress, ) assert.Equal(t, bindAddressDefault, cfg.BindAddress) // An absent SENTRY_DSN is the common case and must stay a normal // start with error reporting off, not a refusal. assert.Empty(t, cfg.SentryDSN) assert.False(t, cfg.SentryEnabled()) }