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) } }