Files
pad/internal/server/middleware_ratelimit_test.go
T
xarmian c8492db29f fix(e2e): disable rate limiting on the E2E server to stop 429 flakes (BUG-2089) (#922)
The E2E harness runs the real pad binary with the real rate limiter, and
every Playwright test shares one loopback IP (127.0.0.1). The auth limiter
(5 logins/min/IP, burst 5) trips as soon as a spec logs in a couple of
browser clients — collab-persistence.spec.ts logs in two per test — so
browserLogin fails with "in-page login failed with status 429". This was
deterministic, not flaky: it failed on TASK-2058's own PR and its push to
main, and on every downstream PR since.

Add a test-only env knob PAD_DISABLE_RATE_LIMITS: when truthy, New() leaves
Server.rateLimiters nil, which RateLimit() already treats as a pass-through
(Stop() and the MCP path are already nil-safe). Wire it into the Playwright
webServer.env; run-pad.mjs spawns the binary with inherited env so it
reaches the pad process. Limiters stay fully active in prod/self-host — the
knob is an explicit opt-in only the E2E server sets.

Verified: collab-persistence.spec.ts passes locally with the fix; the
existing limiter tests still pass (limiters on when the env is unset); new
TestRateLimit_DisabledByEnv pins the bypass.

Claude-Session: https://claude.ai/code/session_015yuBJQYfDj95cgX3DaD8SF
2026-07-11 14:30:32 -04:00

176 lines
5.4 KiB
Go

package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestRateLimit_AuthEndpointLimited(t *testing.T) {
srv := testServer(t)
// Bootstrap so auth endpoints actually process (not just "setup required")
bootstrapFirstUser(t, srv, "admin@test.com", "Admin")
// Login attempts should be rate-limited after burst (5)
for i := 0; i < 5; i++ {
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login",
strings.NewReader(`{"email":"wrong@test.com","password":"wrong"}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "10.0.0.1:1234"
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
// These should go through (even if returning 401)
if w.Code == http.StatusTooManyRequests {
t.Fatalf("request %d should not be rate-limited yet", i+1)
}
}
// The 6th should be rate-limited
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login",
strings.NewReader(`{"email":"wrong@test.com","password":"wrong"}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "10.0.0.1:1234"
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusTooManyRequests {
t.Errorf("expected 429 after burst, got %d", w.Code)
}
// Check Retry-After header
if w.Header().Get("Retry-After") == "" {
t.Error("expected Retry-After header on 429 response")
}
}
// TestRateLimit_DisabledByEnv pins BUG-2089: PAD_DISABLE_RATE_LIMITS=1
// makes the server skip all rate limiting (nil rateLimiters → RateLimit
// is a pass-through), so the E2E harness — where every test shares one
// loopback IP — doesn't trip the auth limiter. Without the env the same
// burst returns 429 (see TestRateLimit_AuthEndpointLimited).
func TestRateLimit_DisabledByEnv(t *testing.T) {
t.Setenv("PAD_DISABLE_RATE_LIMITS", "1")
srv := testServer(t)
if srv.rateLimiters != nil {
t.Fatal("PAD_DISABLE_RATE_LIMITS=1 must leave rateLimiters nil")
}
bootstrapFirstUser(t, srv, "admin@test.com", "Admin")
// Well past the burst of 5 — none may be rate-limited.
for i := 0; i < 20; i++ {
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login",
strings.NewReader(`{"email":"wrong@test.com","password":"wrong"}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "10.0.0.1:1234"
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code == http.StatusTooManyRequests {
t.Fatalf("request %d hit 429 with rate limiting disabled", i+1)
}
}
}
func TestRateLimit_DifferentIPsNotAffected(t *testing.T) {
srv := testServer(t)
bootstrapFirstUser(t, srv, "admin@test.com", "Admin")
// Exhaust rate limit for IP 10.0.0.1
for i := 0; i < 6; i++ {
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login",
strings.NewReader(`{"email":"wrong@test.com","password":"wrong"}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "10.0.0.1:1234"
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
}
// Different IP should still be allowed
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login",
strings.NewReader(`{"email":"wrong@test.com","password":"wrong"}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "10.0.0.2:1234"
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code == http.StatusTooManyRequests {
t.Error("different IP should not be rate-limited")
}
}
func TestRateLimit_SearchEndpointLimited(t *testing.T) {
srv := testServer(t)
// Search limiter has burst=10, so first 10 should succeed
for i := 0; i < 10; i++ {
req := httptest.NewRequest(http.MethodGet, "/api/v1/search?q=test", nil)
req.RemoteAddr = "10.0.0.3:1234"
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code == http.StatusTooManyRequests {
t.Fatalf("request %d should not be rate-limited yet (search burst=10)", i+1)
}
}
// The 11th should be rate-limited
req := httptest.NewRequest(http.MethodGet, "/api/v1/search?q=test", nil)
req.RemoteAddr = "10.0.0.3:1234"
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusTooManyRequests {
t.Errorf("expected 429 after search burst, got %d", w.Code)
}
}
func TestRateLimit_NonAPIPathsExempt(t *testing.T) {
srv := testServer(t)
// Non-API paths should not be rate-limited
for i := 0; i < 50; i++ {
req := httptest.NewRequest(http.MethodGet, "/login", nil)
req.RemoteAddr = "10.0.0.4:1234"
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code == http.StatusTooManyRequests {
t.Fatalf("non-API request %d should not be rate-limited", i+1)
}
}
}
func TestClientIP(t *testing.T) {
// clientIP only reads RemoteAddr (proxy headers are handled by chimiddleware.RealIP)
tests := []struct {
name string
remoteAddr string
want string
}{
{"with port", "192.168.1.1:1234", "192.168.1.1"},
{"no port", "10.0.0.1", "10.0.0.1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = tt.remoteAddr
got := clientIP(req)
if got != tt.want {
t.Errorf("clientIP() = %q, want %q", got, tt.want)
}
})
}
}
func TestClientIP_IgnoresProxyHeaders(t *testing.T) {
// Ensure clientIP does NOT trust X-Real-IP or X-Forwarded-For
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = "192.168.1.1:1234"
req.Header.Set("X-Real-IP", "10.0.0.99")
req.Header.Set("X-Forwarded-For", "10.0.0.88")
got := clientIP(req)
if got != "192.168.1.1" {
t.Errorf("clientIP should ignore proxy headers, got %q", got)
}
}