Files
Crussell/backend/mw/ratelimit_test.go
T
popertots 3866cc5963 fix: round-2 loop-A fresh review (503c326 baseline) — B1 replay cap, A6 discount record, 2FA reissue+cooldown, notification flood, lockout saturation, VAT/refund-status consolidation
Round 2 Loop A fresh money/security/dup-mod review. 23 findings fixed:

MONEY:
- CRITICAL: B1 duplicate auto-refund gains an attempt cap (b1_attempts col, cap 3) —
  a rejected auto-refund no longer re-replays the expired key every sweep run
  (which minted a stacking unauthorized charge each time); FAILED-webhook
  demotion respects the cap; never re-replay a key whose B1 refund failed
- HIGH: A6 deposit_covered_by_discount skip path now APPLIES the eligible
  campaign discount rows immediately (capped) instead of skipping with no
  discount recorded — no more promised-discount-not-recorded overcharge
- MEDIUM: 2FA code burned by the SAVE gate is re-issued on failed
  new-card+save_card charges (re-issue guard now covers req.SaveCard)
- LOW: GetBookingPaymentSummary excludes tip rows from paidAmount (remaining
  now matches the authoritative tip-excluded balance)

SECURITY:
- MEDIUM: unacknowledged CRITICAL admin-notification flood capped (global cap
  on critical_payment_log + refresh_token_reuse rows)
- MEDIUM: 2FA reissue no longer bypasses the mint cooldown (Check no longer
  clears LastMintAt on gate-verify; cleared on terminal charge success)
- MEDIUM: twofa.StateFor map-saturation returns a shared permanently-locked
  state instead of a fresh 5-guess budget per request
- MEDIUM: ProgressiveRateLimit rejects 429 past maxProgressiveSleepDelayMs
  instead of sleeping unboundedly; login bcrypt concurrency semaphore added
- LOW: loginInProgress 409->429; webhook key-set/URL-unset startup check;
  email-verification per-user attempt counter

DUP/MOD:
- formatCurrency single source (frontend format.ts, 7 files consolidated);
  SquareRefundStatusToLocal single source (errors.go, all sites); admin
  audit-log helper dedup; SCA retry model unified (proactive on all 6
  surfaces); buyDailyTotal/daily-cap mirror via backend; lock TTL from
  backend; generateUUID at all card-form sites; magic numbers named
  (defaultPostgresHost, epsilon, fee constants); admin CASH + gift-card
  terminal charges now audited; DAV_SKIP_INIT documented in manuals

Verified: 26/26 dev + 24/24 prod (GO_TESTING=1, the CI condition), both vet
tags, frontend tests+build, env-docs 42/42.
2026-08-22 00:34:50 +01:00

696 lines
25 KiB
Go

package mw
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"crussell/clock"
"github.com/go-chi/chi/v5/middleware"
)
// TestProgressiveRateLimiter_CleanupRemovesStaleEntries verifies that
// IPs with no activity for 60s are cleaned up.
func TestProgressiveRateLimiter_CleanupRemovesStaleEntries(t *testing.T) {
prl := NewProgressiveRateLimiter()
prl.mu.Lock()
prl.requests["stale-ip"] = &ipProgressiveState{
timestamps: []time.Time{clock.Now().Add(-120 * time.Second)},
}
prl.mu.Unlock()
prl.Cleanup()
prl.mu.RLock()
_, exists := prl.requests["stale-ip"]
prl.mu.RUnlock()
if exists {
t.Error("expected stale IP to be cleaned up")
}
}
// ============================================================
// RateLimiter Cleanup Tests
// ============================================================
// TestRateLimiter_Cleanup verifies that stale entries are removed.
func TestRateLimiter_Cleanup(t *testing.T) {
rl := NewRateLimiter(10, time.Minute)
rl.mu.Lock()
rl.requests["stale-key"] = []time.Time{clock.Now().Add(-5 * time.Minute)}
rl.requests["fresh-key"] = []time.Time{clock.Now()}
rl.mu.Unlock()
rl.Cleanup()
rl.mu.RLock()
_, staleExists := rl.requests["stale-key"]
_, freshExists := rl.requests["fresh-key"]
rl.mu.RUnlock()
if staleExists {
t.Error("expected stale-key to be removed")
}
if !freshExists {
t.Error("expected fresh-key to be preserved")
}
}
// TestRateLimiter_Cleanup_EmptyMap handles an empty requests map.
func TestRateLimiter_Cleanup_EmptyMap(t *testing.T) {
rl := NewRateLimiter(10, time.Minute)
rl.mu.Lock()
rl.requests = make(map[string][]time.Time)
rl.mu.Unlock()
rl.Cleanup()
rl.mu.RLock()
count := len(rl.requests)
rl.mu.RUnlock()
if count != 0 {
t.Errorf("expected empty map, got %d entries", count)
}
}
// TestCleanupAllRateLimiters iterates all registered limiters.
func TestCleanupAllRateLimiters(t *testing.T) {
registeredLimitersMu.Lock()
saved := registeredLimiters
registeredLimitersMu.Unlock()
defer func() {
registeredLimitersMu.Lock()
registeredLimiters = saved
registeredLimitersMu.Unlock()
}()
rl1 := NewRateLimiter(10, time.Minute)
rl2 := NewRateLimiter(20, time.Minute)
rl1.mu.Lock()
rl1.requests["rl1-stale"] = []time.Time{clock.Now().Add(-5 * time.Minute)}
rl1.mu.Unlock()
rl2.mu.Lock()
rl2.requests["rl2-stale"] = []time.Time{clock.Now().Add(-5 * time.Minute)}
rl2.mu.Unlock()
_, err := CleanupAllRateLimiters(context.Background())
if err != nil {
t.Errorf("expected nil error, got %v", err)
}
rl1.mu.RLock()
_, rl1Stale := rl1.requests["rl1-stale"]
rl1.mu.RUnlock()
rl2.mu.RLock()
_, rl2Stale := rl2.requests["rl2-stale"]
rl2.mu.RUnlock()
if rl1Stale {
t.Error("expected rl1-stale to be removed")
}
if rl2Stale {
t.Error("expected rl2-stale to be removed")
}
}
// TestCleanupAllRateLimiters_Empty does not panic with no registered limiters.
func TestCleanupAllRateLimiters_Empty(t *testing.T) {
registeredLimitersMu.Lock()
saved := registeredLimiters
registeredLimiters = nil
registeredLimitersMu.Unlock()
defer func() {
registeredLimitersMu.Lock()
registeredLimiters = saved
registeredLimitersMu.Unlock()
}()
_, err := CleanupAllRateLimiters(context.Background())
if err != nil {
t.Errorf("expected nil error, got %v", err)
}
}
// TestCleanupProgressiveRateLimiter cleans the global limiter.
func TestCleanupProgressiveRateLimiter(t *testing.T) {
globalProgressiveLimiter.mu.Lock()
saved := globalProgressiveLimiter.requests
globalProgressiveLimiter.requests = map[string]*ipProgressiveState{
"global-stale": {timestamps: []time.Time{clock.Now().Add(-120 * time.Second)}},
}
globalProgressiveLimiter.mu.Unlock()
defer func() {
globalProgressiveLimiter.mu.Lock()
globalProgressiveLimiter.requests = saved
globalProgressiveLimiter.mu.Unlock()
}()
_, err := CleanupProgressiveRateLimiter(context.Background())
if err != nil {
t.Errorf("expected nil error, got %v", err)
}
globalProgressiveLimiter.mu.RLock()
_, exists := globalProgressiveLimiter.requests["global-stale"]
globalProgressiveLimiter.mu.RUnlock()
if exists {
t.Error("expected global-stale to be removed")
}
}
// ============================================================
// clientIP — per-IP rate-limit key derivation (batch-1 fix)
// ============================================================
// setTrustProxyHeaders temporarily overrides the package-level
// trustProxyHeaders flag so clientIP()'s priority order can be exercised in
// both states. The flag is an init-time capture of TRUST_PROXY_HEADERS (see
// ratelimit_shared.go) that cannot be re-read after process start; the tests
// live in package mw, so the unexported var is reachable directly.
func setTrustProxyHeaders(t *testing.T, v bool) {
t.Helper()
saved := trustProxyHeaders
trustProxyHeaders = v
t.Cleanup(func() { trustProxyHeaders = saved })
}
// TestClientIP_CFConnectingIP_HonoredWhenTrusted verifies that with
// TRUST_PROXY_HEADERS=true a CF-Connecting-IP header becomes the rate-limit
// key (a trusted edge has already overwritten it with the real client IP).
func TestClientIP_CFConnectingIP_HonoredWhenTrusted(t *testing.T) {
setTrustProxyHeaders(t, true)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.1:1234"
req.Header.Set("CF-Connecting-IP", "203.0.113.7")
if got := clientIP(req); got != "203.0.113.7" {
t.Errorf("expected trusted CF-Connecting-IP to win, got %q", got)
}
}
// TestClientIP_CFConnectingIP_IgnoredWhenUntrusted verifies the default
// TRUST_PROXY_HEADERS=false behaviour: a client-supplied CF-Connecting-IP is
// ignored so an origin-exposed backend can never let a client forge its own
// rate-limit key.
func TestClientIP_CFConnectingIP_IgnoredWhenUntrusted(t *testing.T) {
setTrustProxyHeaders(t, false)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.1:1234"
req.Header.Set("CF-Connecting-IP", "203.0.113.7")
if got := clientIP(req); got != "192.0.2.1" {
t.Errorf("expected untrusted CF-Connecting-IP to be ignored, got %q", got)
}
}
// TestClientIP_XRealIPContext_WhenMiddlewareRegistered verifies the chi
// ClientIPFromHeader("X-Real-IP") context path: the X-Real-IP value nginx sets
// from $remote_addr is used as the key once the middleware has captured it.
func TestClientIP_XRealIPContext_WhenMiddlewareRegistered(t *testing.T) {
setTrustProxyHeaders(t, true)
var got string
h := middleware.ClientIPFromHeader("X-Real-IP")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = clientIP(r)
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.1:1234"
req.Header.Set("X-Real-IP", "198.51.100.42")
h.ServeHTTP(httptest.NewRecorder(), req)
if got != "198.51.100.42" {
t.Errorf("expected X-Real-IP from context, got %q", got)
}
}
// TestClientIP_CFWinsOverContext verifies the priority order when both a
// trusted CF-Connecting-IP header and a context client IP are present: the
// CF header is priority 1, the context (X-Real-IP) value priority 2.
func TestClientIP_CFWinsOverContext(t *testing.T) {
setTrustProxyHeaders(t, true)
var got string
h := middleware.ClientIPFromHeader("X-Real-IP")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = clientIP(r)
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.1:1234"
req.Header.Set("CF-Connecting-IP", "203.0.113.9")
req.Header.Set("X-Real-IP", "198.51.100.42")
h.ServeHTTP(httptest.NewRecorder(), req)
if got != "203.0.113.9" {
t.Errorf("expected CF-Connecting-IP to beat the context value, got %q", got)
}
}
// TestClientIP_ContextBeatsRemoteAddrEvenWhenCFUntrusted verifies the context
// value (set by the middleware) is read unconditionally — clientIP does not
// re-check trustProxyHeaders for it — so a spoofed CF header with no trusted
// edge cannot override a middleware-captured value.
func TestClientIP_ContextBeatsRemoteAddrEvenWhenCFUntrusted(t *testing.T) {
setTrustProxyHeaders(t, false)
var got string
h := middleware.ClientIPFromHeader("X-Real-IP")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = clientIP(r)
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.1:1234"
req.Header.Set("CF-Connecting-IP", "203.0.113.7")
req.Header.Set("X-Real-IP", "198.51.100.42")
h.ServeHTTP(httptest.NewRecorder(), req)
if got != "198.51.100.42" {
t.Errorf("expected context client IP to beat RemoteAddr, got %q", got)
}
}
// TestClientIP_FallbackToRemoteAddr verifies the last-resort key source: the
// TCP peer from r.RemoteAddr once the port is split off.
func TestClientIP_FallbackToRemoteAddr(t *testing.T) {
setTrustProxyHeaders(t, false)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.1:1234"
if got := clientIP(req); got != "192.0.2.1" {
t.Errorf("expected RemoteAddr host as fallback, got %q", got)
}
}
// TestClientIP_RemoteAddrWithoutPort_ReturnedAsIs verifies that a RemoteAddr
// lacking a port (no SplitHostPort success) is returned verbatim rather than
// being dropped.
func TestClientIP_RemoteAddrWithoutPort_ReturnedAsIs(t *testing.T) {
setTrustProxyHeaders(t, false)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.5"
if got := clientIP(req); got != "192.0.2.5" {
t.Errorf("expected portless RemoteAddr to pass through, got %q", got)
}
}
// ============================================================
// RateLimit middleware — 429 on burst, pass-through under limit,
// per-key buckets (batch-1 fix regression)
// ============================================================
// newRateLimitTestHandler builds a RateLimit-wrapped handler that records how
// many times the inner handler was reached.
func newRateLimitTestHandler(limit int, window time.Duration) (http.Handler, *int) {
calls := 0
handler := RateLimit(limit, window)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusOK)
}))
return handler, &calls
}
func serveRateLimitRequest(t *testing.T, h http.Handler, remoteAddr string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = remoteAddr
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
return w
}
// TestRateLimitMiddleware_RequestsUnderLimitPassThrough verifies requests
// within the per-IP limit reach the handler untouched.
func TestRateLimitMiddleware_RequestsUnderLimitPassThrough(t *testing.T) {
handler, calls := newRateLimitTestHandler(2, time.Minute)
for i := 0; i < 2; i++ {
w := serveRateLimitRequest(t, handler, "192.0.2.10:1234")
if w.Code != http.StatusOK {
t.Fatalf("request %d: expected 200, got %d (body: %s)", i+1, w.Code, w.Body.String())
}
}
if *calls != 2 {
t.Errorf("expected 2 handler calls, got %d", *calls)
}
}
// TestRateLimitMiddleware_BurstAboveLimitReturns429 verifies the request that
// crosses the limit is answered 429 and the inner handler is never reached.
func TestRateLimitMiddleware_BurstAboveLimitReturns429(t *testing.T) {
handler, calls := newRateLimitTestHandler(2, time.Minute)
for i := 0; i < 2; i++ {
if w := serveRateLimitRequest(t, handler, "192.0.2.11:1234"); w.Code != http.StatusOK {
t.Fatalf("request %d: expected 200, got %d", i+1, w.Code)
}
}
w := serveRateLimitRequest(t, handler, "192.0.2.11:1234")
if w.Code != http.StatusTooManyRequests {
t.Errorf("expected 429 on the request above the limit, got %d", w.Code)
}
if body := w.Body.String(); !strings.Contains(body, "Rate limit exceeded") {
t.Errorf("expected a rate-limit error body, got %q", body)
}
if *calls != 2 {
t.Errorf("expected inner handler to be called exactly twice, got %d", *calls)
}
}
// TestRateLimitMiddleware_PerKeyBuckets verifies different IPs get independent
// buckets: exhausting one IP must not exhaust another.
func TestRateLimitMiddleware_PerKeyBuckets(t *testing.T) {
handler, _ := newRateLimitTestHandler(2, time.Minute)
// Exhaust IP A.
for i := 0; i < 2; i++ {
if w := serveRateLimitRequest(t, handler, "192.0.2.20:1234"); w.Code != http.StatusOK {
t.Fatalf("A request %d: expected 200, got %d", i+1, w.Code)
}
}
if w := serveRateLimitRequest(t, handler, "192.0.2.20:1234"); w.Code != http.StatusTooManyRequests {
t.Errorf("expected IP A to be rate-limited after its burst, got %d", w.Code)
}
// IP B is a separate bucket and still has its full allowance.
for i := 0; i < 2; i++ {
if w := serveRateLimitRequest(t, handler, "192.0.2.21:1234"); w.Code != http.StatusOK {
t.Fatalf("B request %d: expected 200 (independent bucket), got %d", i+1, w.Code)
}
}
if w := serveRateLimitRequest(t, handler, "192.0.2.21:1234"); w.Code != http.StatusTooManyRequests {
t.Errorf("expected IP B to be rate-limited only after ITS OWN burst, got %d", w.Code)
}
}
// ============================================================
// RateLimitByUserAndIP — user+IP-keyed middleware (A7 fix:
// per-IP limiter collapsing to a global budget behind a proxy)
// ============================================================
// newRateLimitByUserAndIPTestHandler builds a RateLimitByUserAndIP-wrapped
// handler that records how many times the inner handler was reached.
func newRateLimitByUserAndIPTestHandler(limit int, window time.Duration) (http.Handler, *int) {
calls := 0
handler := RateLimitByUserAndIP(limit, window)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusOK)
}))
return handler, &calls
}
// serveRateLimitUserRequest serves a request with an optional authenticated
// userID in context (the equivalent of RequireAuth having run) through h.
func serveRateLimitUserRequest(t *testing.T, h http.Handler, userID, remoteAddr string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = remoteAddr
if userID != "" {
req = req.WithContext(context.WithValue(req.Context(), UserIDKey, userID))
}
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
return w
}
// TestRateLimitByUserAndIP_PerUserBucketsBehindSameIP verifies the keyed
// limiter's core guarantee: two different users behind the SAME proxy IP (the
// TRUST_PROXY_HEADERS=false nginx scenario, where clientIP returns the proxy's
// address for everyone) get INDEPENDENT budgets — one user exhausting their
// 10/min allowance can never 429 the other user's 2FA setup/verify/disable.
func TestRateLimitByUserAndIP_PerUserBucketsBehindSameIP(t *testing.T) {
handler, calls := newRateLimitByUserAndIPTestHandler(2, time.Minute)
// User A exhausts its own budget from the shared proxy IP.
for i := 0; i < 2; i++ {
if w := serveRateLimitUserRequest(t, handler, "user-a", "10.0.0.5:1234"); w.Code != http.StatusOK {
t.Fatalf("A request %d: expected 200, got %d", i+1, w.Code)
}
}
if w := serveRateLimitUserRequest(t, handler, "user-a", "10.0.0.5:1234"); w.Code != http.StatusTooManyRequests {
t.Errorf("expected user A to be rate-limited after its own burst, got %d", w.Code)
}
// User B shares the proxy IP but must keep its own full allowance.
for i := 0; i < 2; i++ {
if w := serveRateLimitUserRequest(t, handler, "user-b", "10.0.0.5:1234"); w.Code != http.StatusOK {
t.Fatalf("B request %d: expected 200 (independent user bucket), got %d", i+1, w.Code)
}
}
if w := serveRateLimitUserRequest(t, handler, "user-b", "10.0.0.5:1234"); w.Code != http.StatusTooManyRequests {
t.Errorf("expected user B to be limited only after ITS OWN burst, got %d", w.Code)
}
if *calls != 4 {
t.Errorf("expected exactly 4 handler calls (2 per user), got %d", *calls)
}
}
// TestRateLimitByUserAndIP_SameUserSharesOneBudget verifies the same user's
// requests across all four 2FA routes count against one shared budget (the
// "one shared limiter for all four routes" contract).
func TestRateLimitByUserAndIP_SameUserSharesOneBudget(t *testing.T) {
handler, _ := newRateLimitByUserAndIPTestHandler(3, time.Minute)
for i := 0; i < 3; i++ {
if w := serveRateLimitUserRequest(t, handler, "user-c", "198.51.100.15:1234"); w.Code != http.StatusOK {
t.Fatalf("request %d: expected 200, got %d", i+1, w.Code)
}
}
if w := serveRateLimitUserRequest(t, handler, "user-c", "198.51.100.15:1234"); w.Code != http.StatusTooManyRequests {
t.Errorf("expected the 4th request from the same user to be limited, got %d", w.Code)
}
}
// TestRateLimitByUserAndIP_UnauthenticatedFallsBackToIP verifies the fallback:
// without a userID in context the key is the client IP alone (same behaviour
// as RateLimit), so the middleware stays safe on unauthenticated paths.
func TestRateLimitByUserAndIP_UnauthenticatedFallsBackToIP(t *testing.T) {
handler, _ := newRateLimitByUserAndIPTestHandler(1, time.Minute)
if w := serveRateLimitUserRequest(t, handler, "", "198.51.100.20:1234"); w.Code != http.StatusOK {
t.Fatalf("expected 200 for the first request, got %d", w.Code)
}
if w := serveRateLimitUserRequest(t, handler, "", "198.51.100.20:1234"); w.Code != http.StatusTooManyRequests {
t.Errorf("expected a second unauthenticated request from the same IP to be limited, got %d", w.Code)
}
if w := serveRateLimitUserRequest(t, handler, "", "198.51.100.21:1234"); w.Code != http.StatusOK {
t.Errorf("expected a different IP to keep its own bucket, got %d", w.Code)
}
}
// ============================================================
// RateLimitByUser — user-keyed middleware (B8: the 2FA + gift-card
// redeem budgets must survive IP rotation / header spoofing)
// ============================================================
// newRateLimitByUserTestHandler builds a RateLimitByUser-wrapped handler that
// records how many times the inner handler was reached.
func newRateLimitByUserTestHandler(limit int, window time.Duration) (http.Handler, *int) {
calls := 0
handler := RateLimitByUser(limit, window)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusOK)
}))
return handler, &calls
}
// TestRateLimitByUser_OneBudgetPerUserRegardlessOfIP verifies the B8 fix: the
// 2FA/gift-card-redeem budget keys on the userID ALONE, so an attacker who
// rotates the client IP (or spoofs CF-Connecting-IP behind a misconfigured
// TRUST_PROXY_HEADERS=true proxy) cannot mint a fresh bucket per IP for the
// same account. One user exhausting their budget is limited even from a brand
// new IP, while a DIFFERENT user keeps an independent budget.
func TestRateLimitByUser_OneBudgetPerUserRegardlessOfIP(t *testing.T) {
handler, calls := newRateLimitByUserTestHandler(2, time.Minute)
// User A exhausts its 2/min budget from IP1...
for i := 0; i < 2; i++ {
if w := serveRateLimitUserRequest(t, handler, "user-a", "198.51.100.1:1234"); w.Code != http.StatusOK {
t.Fatalf("A request %d: expected 200, got %d", i+1, w.Code)
}
}
// ...and is STILL limited from IP2 — the IP component is not part of the
// key, so rotating it cannot mint a fresh bucket (the B8 bypass).
if w := serveRateLimitUserRequest(t, handler, "user-a", "198.51.100.2:1234"); w.Code != http.StatusTooManyRequests {
t.Errorf("expected user A to stay limited after rotating IP, got %d", w.Code)
}
// A different user keeps its own full allowance even from the SAME IPs.
for i := 0; i < 2; i++ {
if w := serveRateLimitUserRequest(t, handler, "user-b", "198.51.100.1:1234"); w.Code != http.StatusOK {
t.Fatalf("B request %d: expected 200 (independent user bucket), got %d", i+1, w.Code)
}
}
if w := serveRateLimitUserRequest(t, handler, "user-b", "198.51.100.2:1234"); w.Code != http.StatusTooManyRequests {
t.Errorf("expected user B to be limited only after ITS OWN burst, got %d", w.Code)
}
if *calls != 4 {
t.Errorf("expected exactly 4 handler calls (2 per user), got %d", *calls)
}
}
// TestRateLimitByUser_UnauthenticatedFallsBackToIP verifies the fallback: with
// no userID in context the key is the client IP alone, so the middleware stays
// safe on unauthenticated paths.
func TestRateLimitByUser_UnauthenticatedFallsBackToIP(t *testing.T) {
handler, _ := newRateLimitByUserTestHandler(1, time.Minute)
if w := serveRateLimitUserRequest(t, handler, "", "198.51.100.30:1234"); w.Code != http.StatusOK {
t.Fatalf("expected 200 for the first request, got %d", w.Code)
}
if w := serveRateLimitUserRequest(t, handler, "", "198.51.100.30:1234"); w.Code != http.StatusTooManyRequests {
t.Errorf("expected a second unauthenticated request from the same IP to be limited, got %d", w.Code)
}
if w := serveRateLimitUserRequest(t, handler, "", "198.51.100.31:1234"); w.Code != http.StatusOK {
t.Errorf("expected a different IP to keep its own bucket, got %d", w.Code)
}
}
// ============================================================
// ProgressiveRateLimiter.Check — dual-window progressive delay
// algorithm (batch-1 fix regression)
// ============================================================
// seedProgressiveTimestamps arms prl.requests[ip] so the NEXT Check() call
// observes exactly `burst` timestamps inside the 5s window and `sustained`
// inside the 60s window. Check() appends its own timestamp first, so one fewer
// is seeded: `burst-1` recent timestamps (now-1s, inside the burst window) and
// `sustained-burst` older-but-in-window timestamps (now-10s, outside the 5s
// burst window but inside the 60s sustained window).
func seedProgressiveTimestamps(t *testing.T, prl *ProgressiveRateLimiter, ip string, burst, sustained int) {
t.Helper()
if sustained < burst {
t.Fatalf("sustained (%d) must be >= burst (%d)", sustained, burst)
}
now := clock.Now()
ts := make([]time.Time, 0, sustained-1)
for i := 0; i < burst-1; i++ {
ts = append(ts, now.Add(-time.Second))
}
for i := 0; i < sustained-burst; i++ {
ts = append(ts, now.Add(-10*time.Second))
}
prl.mu.Lock()
prl.requests[ip] = &ipProgressiveState{timestamps: ts}
prl.mu.Unlock()
}
// TestProgressiveRateLimiter_CheckDelayTiers pins the exact algorithm: no
// delay while burst <= 30 AND sustained <= 120; then delay escalates with the
// sustained rate (500ms / 2s / 5s / 10s tiers).
func TestProgressiveRateLimiter_CheckDelayTiers(t *testing.T) {
cases := []struct {
name string
burst int
sustained int
wantMs int
}{
{"under both thresholds is free", 30, 120, 0},
{"burst alone trips the lowest tier", 31, 120, 500},
{"sustained alone trips the lowest tier", 30, 121, 500},
{"500ms tier ceiling", 31, 140, 500},
{"2s tier floor", 31, 141, 2000},
{"2s tier ceiling", 31, 200, 2000},
{"5s tier floor", 31, 201, 5000},
{"5s tier ceiling", 31, 300, 5000},
{"10s abuse tier", 31, 301, 10000},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
prl := NewProgressiveRateLimiter()
ip := "198.51.100.7"
seedProgressiveTimestamps(t, prl, ip, tc.burst, tc.sustained)
if got := prl.Check(ip); got != tc.wantMs {
t.Errorf("burst=%d sustained=%d: expected %dms delay, got %dms", tc.burst, tc.sustained, tc.wantMs, got)
}
})
}
}
// TestProgressiveRateLimiter_DelayEscalatesWithSustainedRate verifies the
// delay increases as a sustained high rate climbs through the tiers on
// repeated hits.
func TestProgressiveRateLimiter_DelayEscalatesWithSustainedRate(t *testing.T) {
prl := NewProgressiveRateLimiter()
ip := "203.0.113.42"
// Start already over the burst limit so every check is throttled.
seedProgressiveTimestamps(t, prl, ip, 31, 31)
steps := []struct {
extraSustained int
wantMs int
}{
{0, 500}, // observed sustained=31 → 500ms tier
{109, 2000}, // observed sustained=141 → 2s tier
{60, 5000}, // observed sustained=202 → 5s tier
{100, 10000}, // observed sustained=303 → 10s abuse tier
}
for i, s := range steps {
if s.extraSustained > 0 {
prl.mu.Lock()
state := prl.requests[ip]
for j := 0; j < s.extraSustained; j++ {
state.timestamps = append(state.timestamps, clock.Now().Add(-6*time.Second))
}
prl.mu.Unlock()
}
if got := prl.Check(ip); got != s.wantMs {
t.Errorf("step %d: expected %dms delay, got %dms", i, s.wantMs, got)
}
}
}
// TestProgressiveRateLimit_RejectsBeyondSleepCap pins finding 4a: once the
// computed delay exceeds maxProgressiveSleepDelayMs (2s), the middleware
// rejects the request 429 immediately instead of sleeping a goroutine for
// 5-10s (a per-client goroutine-parking amplifier in front of bcrypt). The
// small progressive tiers (500ms / 2s) still sleep.
func TestProgressiveRateLimit_RejectsBeyondSleepCap(t *testing.T) {
globalProgressiveLimiter.mu.Lock()
saved := globalProgressiveLimiter.requests
globalProgressiveLimiter.requests = make(map[string]*ipProgressiveState)
globalProgressiveLimiter.mu.Unlock()
t.Cleanup(func() {
globalProgressiveLimiter.mu.Lock()
globalProgressiveLimiter.requests = saved
globalProgressiveLimiter.mu.Unlock()
})
// Seed the 10s abuse tier (sustained > 300) for this IP.
seedProgressiveTimestamps(t, globalProgressiveLimiter, "198.51.100.99", 31, 320)
nextCalled := false
handler := ProgressiveRateLimit(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "198.51.100.99:1234"
w := httptest.NewRecorder()
start := time.Now()
handler.ServeHTTP(w, req)
if nextCalled {
t.Error("next handler must NOT be called when the delay exceeds the sleep cap")
}
if w.Code != http.StatusTooManyRequests {
t.Errorf("expected 429, got %d", w.Code)
}
if elapsed := time.Since(start); elapsed >= 5*time.Second {
t.Errorf("the 5-10s tiers must reject immediately, not sleep (took %s)", elapsed)
}
}