check / check (push) Waiting to run
The config tests unset variables without restoring them and read whatever the developer's shell exported, so results could differ from one machine to the next. config.ClearEnvForTest, in internal/config/testing.go, unsets every variable in the process environment and, when the test ends, leaves it exactly as it found it; every config test and the first-boot test call it before setting their own. TestEnvPositiveInt and TestEnvPort share one table runner, and the port errors name the bad value. A PORT of zero, below zero, or too large to parse now matches ErrInvalidPort, as one above 65535 already did. The README and the Settings page say an unparseable or non-positive RETENTION_SWEEP_INTERVAL stops startup. Model: opus-5-5
667 lines
16 KiB
Go
667 lines
16 KiB
Go
package config_test
|
|
|
|
import (
|
|
"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"
|
|
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.
|
|
config.ClearEnvForTest(t)
|
|
|
|
if tt.set {
|
|
t.Setenv(testEnvKey, tt.value)
|
|
}
|
|
|
|
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)
|
|
})
|
|
}
|
|
}
|
|
|
|
// envIntCase is one row of the envPositiveInt and envPort tables.
|
|
type envIntCase struct {
|
|
name string
|
|
set bool
|
|
value string
|
|
expectError bool
|
|
errIs error
|
|
expected int
|
|
}
|
|
|
|
// runEnvIntCases runs each row through parse, which is
|
|
// envPositiveInt or envPort, with testEnvKey set to the row's value
|
|
// or left unset.
|
|
func runEnvIntCases(
|
|
t *testing.T,
|
|
parse func(key string, defaultValue int) (int, error),
|
|
defaultValue int,
|
|
tests []envIntCase,
|
|
) {
|
|
t.Helper()
|
|
|
|
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.
|
|
config.ClearEnvForTest(t)
|
|
|
|
if tt.set {
|
|
t.Setenv(testEnvKey, tt.value)
|
|
}
|
|
|
|
got, err := parse(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)
|
|
})
|
|
}
|
|
}
|
|
|
|
//nolint:paralleltest // runEnvIntCases uses t.Setenv.
|
|
func TestEnvPositiveInt(t *testing.T) {
|
|
const defaultValue = 7
|
|
|
|
runEnvIntCases(t, config.EnvPositiveIntForTest, defaultValue, []envIntCase{
|
|
{
|
|
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,
|
|
},
|
|
})
|
|
}
|
|
|
|
//nolint:paralleltest // runEnvIntCases uses t.Setenv.
|
|
func TestEnvPort(t *testing.T) {
|
|
const defaultValue = 8080
|
|
|
|
runEnvIntCases(t, config.EnvPortForTest, defaultValue, []envIntCase{
|
|
{
|
|
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.ErrInvalidPort,
|
|
},
|
|
{
|
|
name: "negative is rejected",
|
|
set: true,
|
|
value: "-1",
|
|
expectError: true,
|
|
errIs: config.ErrInvalidPort,
|
|
},
|
|
{
|
|
name: "above the port range is rejected",
|
|
set: true,
|
|
value: "65536",
|
|
expectError: true,
|
|
errIs: config.ErrInvalidPort,
|
|
},
|
|
{
|
|
name: "too large for an int is rejected",
|
|
set: true,
|
|
value: "99999999999999999999",
|
|
expectError: true,
|
|
errIs: config.ErrInvalidPort,
|
|
},
|
|
})
|
|
}
|
|
|
|
// 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.
|
|
config.ClearEnvForTest(t)
|
|
|
|
if tt.set {
|
|
t.Setenv(testEnvKey, tt.value)
|
|
}
|
|
|
|
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.
|
|
config.ClearEnvForTest(t)
|
|
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,
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
config.ClearEnvForTest(t)
|
|
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
|
|
|
cfg, err := buildConfig(t)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cfg)
|
|
|
|
assert.Equal(t, 8080, cfg.Port)
|
|
assert.False(t, cfg.Debug)
|
|
|
|
// 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())
|
|
}
|