All checks were successful
check / check (push) Successful in 2m40s
With no trusted proxies configured, every client behind the reverse proxy production requires shares one rate-limit bucket per limit, so five POSTs per minute from anywhere holds the login limit full and denies the admin login until restart. The default is still correct; it was the consequence that was invisible. Startup now warns, and the README no longer claims the login limit is per-IP unconditionally.
705 lines
14 KiB
Go
705 lines
14 KiB
Go
package config_test
|
|
|
|
import (
|
|
"bytes"
|
|
"log/slog"
|
|
"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)
|
|
}
|
|
|
|
// TestSharedRateLimitBucketWarning covers the startup warning that
|
|
// tells an operator their production deployment shares one rate-limit
|
|
// bucket between every client, which makes the admin login remotely
|
|
// deniable. It must fire when TRUSTED_PROXIES is empty in production
|
|
// and stay quiet otherwise.
|
|
func TestSharedRateLimitBucketWarning(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
environment string
|
|
trustedProxies string
|
|
expectWarning bool
|
|
}{
|
|
{
|
|
name: "prod without trusted proxies warns",
|
|
environment: config.EnvironmentProd,
|
|
expectWarning: true,
|
|
},
|
|
{
|
|
name: "prod with trusted proxies is quiet",
|
|
environment: config.EnvironmentProd,
|
|
trustedProxies: cidrPrivateV4,
|
|
expectWarning: false,
|
|
},
|
|
{
|
|
// Development is not required to run behind a
|
|
// reverse proxy, so the shared bucket the warning
|
|
// describes is not the expected shape there.
|
|
name: "dev without trusted proxies is quiet",
|
|
environment: config.EnvironmentDev,
|
|
expectWarning: false,
|
|
},
|
|
}
|
|
|
|
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", tt.environment)
|
|
|
|
if tt.trustedProxies == "" {
|
|
require.NoError(
|
|
t, os.Unsetenv("TRUSTED_PROXIES"),
|
|
)
|
|
} else {
|
|
t.Setenv("TRUSTED_PROXIES", tt.trustedProxies)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
|
|
log := slog.New(slog.NewJSONHandler(
|
|
&buf, &slog.HandlerOptions{
|
|
Level: slog.LevelDebug,
|
|
},
|
|
))
|
|
|
|
require.NoError(
|
|
t,
|
|
config.WarnSharedRateLimitBucketForTest(log),
|
|
)
|
|
|
|
if !tt.expectWarning {
|
|
assert.Empty(t, buf.String())
|
|
|
|
return
|
|
}
|
|
|
|
logged := buf.String()
|
|
|
|
assert.Contains(t, logged, `"level":"WARN"`)
|
|
assert.Contains(t, logged, "TRUSTED_PROXIES")
|
|
assert.Contains(t, logged, "shares one bucket")
|
|
assert.Contains(t, logged, "deny the admin login")
|
|
})
|
|
}
|
|
}
|