All checks were successful
check / check (push) Successful in 2m37s
The public receiver /webhook/{uuid} had no rate limiting: anyone
who learns an entrypoint UUID can flood it, inflating the
per-webhook database and the delivery queue.
Add a dedicated limit scoped to the receiver route, keyed per
client IP per request path (the path contains the entrypoint
UUID), so one misbehaving sender is throttled without affecting
other senders of the same entrypoint or other entrypoints.
Requests over the limit get a 429; httprate adds the Retry-After
header per RFC 6585. IP extraction honours X-Forwarded-For,
X-Real-IP, and True-Client-IP for reverse-proxy deployments.
The limit is RECEIVER_RATE_LIMIT requests per minute, default
120. A set-but-unparseable or non-positive value aborts startup
via the new envPositiveInt strict parser rather than silently
falling back to the default. envInt's other callers are
unchanged; converting them is tracked in #80.
Also update the README env table and Rate Limiting design
section, and sync TODO.md.
367 lines
6.2 KiB
Go
367 lines
6.2 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"
|
|
)
|
|
|
|
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
|
|
expected time.Duration
|
|
}{
|
|
{
|
|
name: "unset uses default",
|
|
set: false,
|
|
expected: time.Hour,
|
|
},
|
|
{
|
|
name: "valid value is parsed",
|
|
set: true,
|
|
value: "15m",
|
|
expected: 15 * time.Minute,
|
|
},
|
|
{
|
|
name: "unparseable value fails startup",
|
|
set: true,
|
|
value: "not-a-duration",
|
|
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("RETENTION_SWEEP_INTERVAL", tt.value)
|
|
} else {
|
|
require.NoError(t, os.Unsetenv(
|
|
"RETENTION_SWEEP_INTERVAL",
|
|
))
|
|
}
|
|
|
|
if tt.expectError {
|
|
testRetentionSweepIntervalError(t)
|
|
} else {
|
|
testRetentionSweepIntervalSuccess(t, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func testRetentionSweepIntervalError(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 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 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
|
|
expected int
|
|
}{
|
|
{
|
|
name: "unset uses default",
|
|
set: false,
|
|
expected: 120,
|
|
},
|
|
{
|
|
name: "valid value is parsed",
|
|
set: true,
|
|
value: "30",
|
|
expected: 30,
|
|
},
|
|
{
|
|
name: "unparseable value fails startup",
|
|
set: true,
|
|
value: "not-a-number",
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "zero fails startup",
|
|
set: true,
|
|
value: "0",
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "negative fails startup",
|
|
set: true,
|
|
value: "-5",
|
|
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("RECEIVER_RATE_LIMIT", tt.value)
|
|
} else {
|
|
require.NoError(t, os.Unsetenv(
|
|
"RECEIVER_RATE_LIMIT",
|
|
))
|
|
}
|
|
|
|
if tt.expectError {
|
|
testReceiverRateLimitError(t)
|
|
} else {
|
|
testReceiverRateLimitSuccess(t, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func testReceiverRateLimitError(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 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)
|
|
}
|