feat(infra): rate limit middlewares HTTP por IP y por key + tests
Implementa fase 2 del issue 0016: - rate_limit_middleware: limita por IP (X-Forwarded-For > X-Real-IP > RemoteAddr) - rate_limiter_by_key: middleware configurable con keyFunc custom (API key, user ID...) - Cuando se rechaza responde 429 con HTTPError JSON y headers Retry-After + X-RateLimit-* - Tests con httptest.NewRecorder cubriendo: limite, burst, IPs independientes, XFF prioritario, recarga temporal, JSON body, headers IETF, GC stop idempotente, key vacia salta limit
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRateLimitMiddleware(t *testing.T) {
|
||||
base := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
t.Run("permite request bajo el limite", func(t *testing.T) {
|
||||
rl := RateLimiterNew(10, 20)
|
||||
mw := RateLimitMiddleware(rl)
|
||||
handler := mw(base)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/api", nil)
|
||||
req.RemoteAddr = "192.0.2.1:1234"
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status=%d, want 200", rec.Code)
|
||||
}
|
||||
if rec.Header().Get("X-RateLimit-Limit") != "20" {
|
||||
t.Errorf("X-RateLimit-Limit=%q, want 20", rec.Header().Get("X-RateLimit-Limit"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("responde 429 cuando se excede el limite", func(t *testing.T) {
|
||||
rl := RateLimiterNew(1, 2)
|
||||
mw := RateLimitMiddleware(rl)
|
||||
handler := mw(base)
|
||||
|
||||
// Consumir burst
|
||||
for i := 0; i < 2; i++ {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/api", nil)
|
||||
req.RemoteAddr = "192.0.2.2:1234"
|
||||
handler.ServeHTTP(rec, req)
|
||||
}
|
||||
// Tercer request rechazado
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/api", nil)
|
||||
req.RemoteAddr = "192.0.2.2:1234"
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("status=%d, want 429", rec.Code)
|
||||
}
|
||||
if rec.Header().Get("Retry-After") == "" {
|
||||
t.Error("Retry-After header no seteado en 429")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IPs distintas tienen buckets independientes", func(t *testing.T) {
|
||||
rl := RateLimiterNew(1, 1)
|
||||
mw := RateLimitMiddleware(rl)
|
||||
handler := mw(base)
|
||||
|
||||
// Agotar IP A
|
||||
recA := httptest.NewRecorder()
|
||||
reqA := httptest.NewRequest("GET", "/api", nil)
|
||||
reqA.RemoteAddr = "10.0.0.1:1000"
|
||||
handler.ServeHTTP(recA, reqA)
|
||||
|
||||
recA2 := httptest.NewRecorder()
|
||||
reqA2 := httptest.NewRequest("GET", "/api", nil)
|
||||
reqA2.RemoteAddr = "10.0.0.1:1000"
|
||||
handler.ServeHTTP(recA2, reqA2)
|
||||
if recA2.Code != http.StatusTooManyRequests {
|
||||
t.Fatal("IP A deberia estar limitada")
|
||||
}
|
||||
|
||||
// IP B intacta
|
||||
recB := httptest.NewRecorder()
|
||||
reqB := httptest.NewRequest("GET", "/api", nil)
|
||||
reqB.RemoteAddr = "10.0.0.2:1000"
|
||||
handler.ServeHTTP(recB, reqB)
|
||||
if recB.Code != http.StatusOK {
|
||||
t.Errorf("IP B status=%d, want 200", recB.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("X-Forwarded-For tiene prioridad sobre RemoteAddr", func(t *testing.T) {
|
||||
rl := RateLimiterNew(1, 1)
|
||||
mw := RateLimitMiddleware(rl)
|
||||
handler := mw(base)
|
||||
|
||||
// Request 1 con XFF=A, RemoteAddr=R1
|
||||
rec1 := httptest.NewRecorder()
|
||||
req1 := httptest.NewRequest("GET", "/api", nil)
|
||||
req1.Header.Set("X-Forwarded-For", "203.0.113.1")
|
||||
req1.RemoteAddr = "192.0.2.99:5000"
|
||||
handler.ServeHTTP(rec1, req1)
|
||||
if rec1.Code != http.StatusOK {
|
||||
t.Fatal("primer request deberia pasar")
|
||||
}
|
||||
|
||||
// Request 2 con XFF=A pero RemoteAddr distinto -> mismo bucket A -> rechazado
|
||||
rec2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest("GET", "/api", nil)
|
||||
req2.Header.Set("X-Forwarded-For", "203.0.113.1")
|
||||
req2.RemoteAddr = "192.0.2.100:5000"
|
||||
handler.ServeHTTP(rec2, req2)
|
||||
if rec2.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("XFF deberia identificar misma IP, status=%d", rec2.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("responde con Retry-After header en 429", func(t *testing.T) {
|
||||
rl := RateLimiterNew(1, 1)
|
||||
mw := RateLimitMiddleware(rl)
|
||||
handler := mw(base)
|
||||
|
||||
// Agotar
|
||||
req := httptest.NewRequest("GET", "/api", nil)
|
||||
req.RemoteAddr = "172.16.0.1:1000"
|
||||
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
// Rechazado
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("status=%d, want 429", rec.Code)
|
||||
}
|
||||
if rec.Header().Get("Retry-After") == "" {
|
||||
t.Error("Retry-After vacio en 429")
|
||||
}
|
||||
if rec.Header().Get("Content-Type") != "application/json" {
|
||||
t.Errorf("Content-Type=%q, want application/json", rec.Header().Get("Content-Type"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRateLimitClientIP(t *testing.T) {
|
||||
t.Run("X-Forwarded-For first hop", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.1, 198.51.100.1, 10.0.0.1")
|
||||
req.RemoteAddr = "192.0.2.1:1234"
|
||||
got := rateLimitClientIP(req)
|
||||
if got != "203.0.113.1" {
|
||||
t.Errorf("got %q, want 203.0.113.1", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("X-Real-IP fallback", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("X-Real-IP", "203.0.113.5")
|
||||
req.RemoteAddr = "192.0.2.1:1234"
|
||||
got := rateLimitClientIP(req)
|
||||
if got != "203.0.113.5" {
|
||||
t.Errorf("got %q, want 203.0.113.5", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RemoteAddr cuando no hay headers", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.RemoteAddr = "192.0.2.1:1234"
|
||||
got := rateLimitClientIP(req)
|
||||
if got != "192.0.2.1" {
|
||||
t.Errorf("got %q, want 192.0.2.1", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user