check / check (push) Successful in 4m39s
Unset or empty, TRUSTED_PROXIES now defaults to 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16, so a reverse proxy reaching the app over a Docker network or a private LAN gets per-client rate-limit buckets without configuration. A set value replaces the default; an unparseable one still fails startup. The startup warning for an empty list goes, with its test hook and test, since the default is no longer empty. The README's configuration table, Trusted proxies, upaas and reverse-proxy sections describe the new default and when to narrow it to the proxy alone. Model: opus-5-5
1019 lines
22 KiB
Go
1019 lines
22 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"
|
|
|
|
// metricsAuthValue is the sample METRICS_PASSWORD the metrics
|
|
// credential cases are built from. It is asserted absent from
|
|
// the startup error, so it must not be a substring of either
|
|
// variable name that error prints.
|
|
metricsAuthValue = "s3cret"
|
|
)
|
|
|
|
func TestEnvironmentConfig(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
envValue string
|
|
envVars map[string]string
|
|
expectError bool
|
|
isDev bool
|
|
isProd bool
|
|
}{
|
|
{
|
|
name: "default is prod",
|
|
isDev: false,
|
|
isProd: true,
|
|
},
|
|
{
|
|
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,
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestDataDirHelper pins the exported resolver against the value
|
|
// Config carries. The entry point takes the DATA_DIR lock through the
|
|
// helper before the fx graph exists, so the two disagreeing would mean
|
|
// locking one directory and writing to another.
|
|
func TestDataDirHelper(t *testing.T) {
|
|
for _, set := range []string{"", "/tmp/webhooker-datadir-helper"} {
|
|
name := "set"
|
|
if set == "" {
|
|
name = "unset"
|
|
}
|
|
|
|
t.Run(name, func(t *testing.T) {
|
|
// Cannot use t.Parallel() here because t.Setenv
|
|
// is incompatible with parallel subtests.
|
|
if set == "" {
|
|
require.NoError(t, os.Unsetenv("DATA_DIR"))
|
|
} else {
|
|
t.Setenv("DATA_DIR", set)
|
|
}
|
|
|
|
expected := set
|
|
if expected == "" {
|
|
expected = config.DefaultDataDir
|
|
}
|
|
|
|
assert.Equal(t, expected, config.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) {
|
|
// Unset, the RFC 1918 private ranges are trusted, so a reverse
|
|
// proxy on a Docker network or a private LAN is covered without
|
|
// configuration.
|
|
defaultProxies := []string{cidrPrivateV4, "172.16.0.0/12", "192.168.0.0/16"}
|
|
|
|
tests := []struct {
|
|
name string
|
|
set bool
|
|
value string
|
|
expectError bool
|
|
expected []string
|
|
}{
|
|
{
|
|
name: caseUnsetUsesDefault,
|
|
set: false,
|
|
expected: defaultProxies,
|
|
},
|
|
{
|
|
name: "blank value uses default",
|
|
set: true,
|
|
value: " ",
|
|
expected: defaultProxies,
|
|
},
|
|
{
|
|
name: "set value replaces the default entirely",
|
|
set: true,
|
|
value: "203.0.113.7",
|
|
expected: []string{"203.0.113.7/32"},
|
|
},
|
|
{
|
|
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)
|
|
}
|
|
|
|
// TestAllowedEgressCIDRs covers ALLOWED_EGRESS_CIDRS, the escape
|
|
// hatch that lets a self-hosted deployment forward to its own
|
|
// network. Unset it must stay empty, so the SSRF guard keeps
|
|
// refusing every private/reserved range; a set-but-unparseable
|
|
// value must abort startup naming the variable rather than
|
|
// silently running with a list the operator did not write.
|
|
func TestAllowedEgressCIDRs(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
set bool
|
|
value string
|
|
expected []string
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: caseUnsetUsesDefault,
|
|
set: false,
|
|
expected: []string{},
|
|
},
|
|
{
|
|
name: "empty value yields empty list",
|
|
set: true,
|
|
value: "",
|
|
expected: []string{},
|
|
},
|
|
{
|
|
name: caseValidValueParsed,
|
|
set: true,
|
|
value: cidrPrivateV4,
|
|
expected: []string{cidrPrivateV4},
|
|
},
|
|
{
|
|
name: "multiple blocks with whitespace",
|
|
set: true,
|
|
value: " 10.0.0.0/8 , 127.0.0.0/8 ",
|
|
expected: []string{cidrPrivateV4, "127.0.0.0/8"},
|
|
},
|
|
{
|
|
name: "bare address becomes a single host",
|
|
set: true,
|
|
value: "172.17.0.5",
|
|
expected: []string{"172.17.0.5/32"},
|
|
},
|
|
{
|
|
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("ALLOWED_EGRESS_CIDRS", tt.value)
|
|
} else {
|
|
require.NoError(
|
|
t, os.Unsetenv("ALLOWED_EGRESS_CIDRS"),
|
|
)
|
|
}
|
|
|
|
if tt.expectError {
|
|
expectStartupErrorFor(
|
|
t, "ALLOWED_EGRESS_CIDRS", config.ErrInvalidCIDR,
|
|
)
|
|
} else {
|
|
testAllowedEgressCIDRsSuccess(t, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func testAllowedEgressCIDRsSuccess(
|
|
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()
|
|
|
|
assert.Equal(
|
|
t, expected, config.PrefixStrings(cfg.AllowedEgressCIDRs),
|
|
)
|
|
}
|
|
|
|
// TestEgressAllowlistWarning covers the startup log that shows an
|
|
// operator the hole ALLOWED_EGRESS_CIDRS opened. It must stay
|
|
// silent on the default (empty) list and, when set, print the
|
|
// blocks themselves rather than a count.
|
|
func TestEgressAllowlistWarning(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
allowed string
|
|
expectWarning bool
|
|
}{
|
|
{
|
|
name: "empty allowlist is quiet",
|
|
expectWarning: false,
|
|
},
|
|
{
|
|
name: "non-empty allowlist warns",
|
|
allowed: "10.0.0.0/8,127.0.0.0/8",
|
|
expectWarning: 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", config.EnvironmentDev)
|
|
|
|
if tt.allowed == "" {
|
|
require.NoError(
|
|
t, os.Unsetenv("ALLOWED_EGRESS_CIDRS"),
|
|
)
|
|
} else {
|
|
t.Setenv("ALLOWED_EGRESS_CIDRS", tt.allowed)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
|
|
log := slog.New(slog.NewJSONHandler(
|
|
&buf, &slog.HandlerOptions{
|
|
Level: slog.LevelDebug,
|
|
},
|
|
))
|
|
|
|
require.NoError(
|
|
t, config.WarnEgressAllowlistForTest(log),
|
|
)
|
|
|
|
if !tt.expectWarning {
|
|
assert.Empty(t, buf.String())
|
|
|
|
return
|
|
}
|
|
|
|
logged := buf.String()
|
|
|
|
assert.Contains(t, logged, `"level":"WARN"`)
|
|
assert.Contains(t, logged, "ALLOWED_EGRESS_CIDRS")
|
|
// The blocks themselves, not a count: the operator has
|
|
// to be able to read back which networks are open.
|
|
assert.Contains(t, logged, "10.0.0.0/8")
|
|
assert.Contains(t, logged, "127.0.0.0/8")
|
|
// What stays shut. Asserted on the clause naming the
|
|
// wider set rather than on "Link-local" alone, so the
|
|
// string cannot narrow back to link-local only while
|
|
// the always-blocked set covers ULA, CGNAT and two
|
|
// public metadata addresses as well.
|
|
assert.Contains(t, logged, "metadata endpoints outside it")
|
|
})
|
|
}
|
|
}
|
|
|
|
// metricsEnv describes what one subtest below puts in the
|
|
// environment for a single METRICS_ variable. A variable that is
|
|
// set to the empty string and one that is not set at all are
|
|
// distinct inputs here, because the reported bug arrived through
|
|
// the first of them.
|
|
type metricsEnv struct {
|
|
set bool
|
|
value string
|
|
}
|
|
|
|
// unset leaves the variable out of the environment entirely.
|
|
func unset() metricsEnv {
|
|
return metricsEnv{set: false, value: ""}
|
|
}
|
|
|
|
// setTo sets the variable, including to the empty string.
|
|
func setTo(value string) metricsEnv {
|
|
return metricsEnv{set: true, value: value}
|
|
}
|
|
|
|
// metricsAuthCase is one row of the table in TestMetricsAuthConfig,
|
|
// named so the table can live in its own function and keep the test
|
|
// itself short.
|
|
type metricsAuthCase struct {
|
|
name string
|
|
username metricsEnv
|
|
password metricsEnv
|
|
expectError bool
|
|
expectAuth bool
|
|
}
|
|
|
|
// metricsAuthCases enumerates every combination of the two
|
|
// credentials, counting "set to the empty string" and "not set at
|
|
// all" as separate inputs on each side.
|
|
func metricsAuthCases() []metricsAuthCase {
|
|
return []metricsAuthCase{
|
|
{
|
|
name: "both unset leaves metrics unmounted",
|
|
username: unset(),
|
|
password: unset(),
|
|
},
|
|
{
|
|
name: "both empty leaves metrics unmounted",
|
|
username: setTo(""),
|
|
password: setTo(""),
|
|
},
|
|
{
|
|
name: "both set enables metrics auth",
|
|
username: setTo("metrics"),
|
|
password: setTo(metricsAuthValue),
|
|
expectAuth: true,
|
|
},
|
|
{
|
|
name: "username with unset password fails",
|
|
username: setTo("metrics"),
|
|
password: unset(),
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "username with empty password fails",
|
|
username: setTo("metrics"),
|
|
password: setTo(""),
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "password with unset username fails",
|
|
username: unset(),
|
|
password: setTo(metricsAuthValue),
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "password with empty username fails",
|
|
username: setTo(""),
|
|
password: setTo(metricsAuthValue),
|
|
expectError: true,
|
|
},
|
|
}
|
|
}
|
|
|
|
// TestMetricsAuthConfig covers every combination of METRICS_USERNAME
|
|
// and METRICS_PASSWORD. Either both carry a value, in which case
|
|
// /metrics is served behind basic auth, or neither does, in which
|
|
// case the route is never mounted. One without the other is a
|
|
// startup error rather than a fallback: mounting on the username
|
|
// alone published /metrics behind a credential map that accepted an
|
|
// empty password, which is the defect this test exists to pin. See
|
|
// https://git.eeqj.de/sneak/webhooker/issues/205.
|
|
func TestMetricsAuthConfig(t *testing.T) {
|
|
for _, tt := range metricsAuthCases() {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Cannot use t.Parallel() here because t.Setenv
|
|
// is incompatible with parallel subtests.
|
|
if tt.username.set {
|
|
t.Setenv("METRICS_USERNAME", tt.username.value)
|
|
} else {
|
|
require.NoError(
|
|
t, os.Unsetenv("METRICS_USERNAME"),
|
|
)
|
|
}
|
|
|
|
if tt.password.set {
|
|
t.Setenv("METRICS_PASSWORD", tt.password.value)
|
|
} else {
|
|
require.NoError(
|
|
t, os.Unsetenv("METRICS_PASSWORD"),
|
|
)
|
|
}
|
|
|
|
if tt.expectError {
|
|
assertMetricsAuthRejected(t)
|
|
|
|
return
|
|
}
|
|
|
|
assertMetricsAuthAccepted(t, tt.expectAuth)
|
|
})
|
|
}
|
|
}
|
|
|
|
// assertMetricsAuthRejected requires that fx refused to build the
|
|
// graph, that the failure is ErrIncompleteMetricsAuth, and that the
|
|
// operator is told both variable names — the point of failing here
|
|
// rather than degrading is that the message says what to fix.
|
|
func assertMetricsAuthRejected(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),
|
|
)
|
|
|
|
err := app.Err()
|
|
require.Error(t, err)
|
|
require.ErrorIs(t, err, config.ErrIncompleteMetricsAuth)
|
|
assert.Contains(t, err.Error(), "METRICS_USERNAME")
|
|
assert.Contains(t, err.Error(), "METRICS_PASSWORD")
|
|
// The password is a secret and must not reach a startup error.
|
|
assert.NotContains(t, err.Error(), metricsAuthValue)
|
|
}
|
|
|
|
// assertMetricsAuthAccepted requires that startup succeeded and that
|
|
// MetricsAuthEnabled — the single value the /metrics mount and the
|
|
// startup log both read — reports what the environment asked for.
|
|
func assertMetricsAuthAccepted(t *testing.T, expectAuth 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, expectAuth, cfg.MetricsAuthEnabled())
|
|
}
|