All checks were successful
check / check (push) Successful in 2m52s
With TRUSTED_PROXIES empty behind the reverse proxy production is required to run behind, every login POST keyed on the proxy's address and shared one 5/minute bucket. A stranger sending five POSTs a minute -- 0.08 requests per second, from anywhere -- kept that bucket permanently full, and the operator's own correct password was answered 429 indefinitely with no second administrative path. The login POST no longer has a pre-emptive limiter. The handler verifies credentials first and spends budget only on a FAILED attempt, so a correct password is never throttled whatever the counters hold. Three things follow, and are implemented together because the first is unsafe without the other two: - Failures are counted per (client bucket, submitted username), five per minute, after which further failures get 429 with a Retry-After. A successful login clears the counter, so mistyping and then succeeding does not leave the operator throttled. - Both key sets are capped at 1024 entries. The submitted username is attacker-controlled, so past the first cap failures fall back to a counter keyed on the client alone, and past both caps a failure is answered as throttled without being recorded. Tracked state stays under half a megabyte and does not grow with invented usernames. - Concurrent Argon2id verifications are capped at two, a 128 MB ceiling at 64 MB per hash, and the queue for those slots is capped at 64 waiters. Every password-hashing endpoint takes a slot, including the password-change endpoint, which holds one across both its hashes. A request that waits five seconds without a slot is answered 503, and one that arrives with the queue already full is shed with 503 immediately rather than joining it. Bounding the wait alone would not bound memory: a waiter reaches the guard with its form parsed, so it holds up to the 1 MB body cap for the whole wait, and at flood rates an unbounded queue is worth gigabytes against a 128 MB hashing budget. 64 waiters is 64 MB of committed queue memory, shallow enough that two slots drain a full queue inside the five-second deadline; peak commitment is 128 MB of hashing plus about 66 MB of parsed bodies. An unknown username is verified against a dummy hash instead of returning early, so a nonexistent account costs the same time as a real one and the response cannot be used to enumerate usernames. The password-change limiter is unchanged: RequireAuth runs ahead of it, so only a request already carrying a valid session reaches its bucket. Two consequences are documented rather than fixed, because they follow from the shape the issue asks for. Online guessing throughput rises from 5 a minute to roughly 27 a second, about 2.3 million a day: the credential check always precedes the counter, so the 429 is a label on the response rather than a gate in front of the hash, and what bounds brute force is the semaphore. And under a sustained flood the residual exposure is a loss of login availability, not merely of latency -- above about 27 requests a second most attempts are shed with 503, so a determined flood still denies login for as long as it runs. It costs roughly 400x more to run, nothing accumulates, and the first attempt after it stops succeeds. Restarting the service does not help: the counters a restart clears are not what is saturated. Also adds the missing test for the third bucketKey call site, where the peer is a trusted proxy but the forwarded chain names no client. Every existing test of that fallback uses an IPv4 proxy, where bucketKey is the identity function, so dropping the /64 masking there left the suite green. README and the TRUSTED_PROXIES startup warning updated: a shared bucket now costs precision, not the availability of the admin path.
729 lines
15 KiB
Go
729 lines
15 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 a deployment behind a reverse proxy shares one
|
|
// rate-limit bucket between every client, which turns the receiver
|
|
// limits into service-wide ceilings and collapses login failure
|
|
// counting. It must fire whenever TRUSTED_PROXIES is empty,
|
|
// in any environment: WEBHOOKER_ENVIRONMENT defaults to dev, so gating
|
|
// on it would silence the warning for exactly the operator who never
|
|
// configured the deployment. It stays quiet once proxies are named.
|
|
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,
|
|
},
|
|
{
|
|
// The default environment. An internet-exposed
|
|
// deployment whose operator never set
|
|
// WEBHOOKER_ENVIRONMENT lands here and has exactly
|
|
// the exposure the warning announces.
|
|
name: "dev without trusted proxies warns",
|
|
environment: config.EnvironmentDev,
|
|
expectWarning: true,
|
|
},
|
|
{
|
|
name: "dev with trusted proxies is quiet",
|
|
environment: config.EnvironmentDev,
|
|
trustedProxies: cidrPrivateV4,
|
|
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, "share one bucket")
|
|
assert.Contains(
|
|
t, logged, "throttle every other client's failed logins",
|
|
)
|
|
// The warning must not claim a lockout the login
|
|
// endpoint no longer permits: credentials are verified
|
|
// before any budget is spent.
|
|
assert.Contains(
|
|
t, logged, "a correct password still gets in",
|
|
)
|
|
// The text must stay accurate for a developer with
|
|
// nothing in front of the process, where an empty
|
|
// list costs nothing.
|
|
assert.Contains(
|
|
t, logged, "nothing proxying to this process",
|
|
)
|
|
})
|
|
}
|
|
}
|