689 lines
16 KiB
Go
689 lines
16 KiB
Go
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())
|
|
}
|