All checks were successful
check / check (push) Successful in 6s
A non-positive value reached time.NewTicker in the retention reaper and the archive sweeper, panicking both goroutines after startup had already reported success. envPositiveDuration now rejects it in loadFromEnv, matching how PORT and RECEIVER_RATE_LIMIT fail. SESSION_IDLE_TIMEOUT keeps treating non-positive as disabled, which is guarded at every use site.
627 lines
12 KiB
Go
627 lines
12 KiB
Go
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
|
|
// 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,
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|