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,110 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRateLimiterByKey(t *testing.T) {
|
||||
base := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
keyFunc := func(r *http.Request) string {
|
||||
return r.Header.Get("X-API-Key")
|
||||
}
|
||||
|
||||
t.Run("aplica limit por la clave devuelta por keyFunc", func(t *testing.T) {
|
||||
rl := RateLimiterNew(1, 2)
|
||||
mw := RateLimiterByKey(rl, keyFunc)
|
||||
handler := mw(base)
|
||||
|
||||
// Agotar tokens de api-key-A
|
||||
for i := 0; i < 2; i++ {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("X-API-Key", "key-A")
|
||||
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
}
|
||||
// Tercer rechazado
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("X-API-Key", "key-A")
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("status=%d, want 429", rec.Code)
|
||||
}
|
||||
|
||||
// key-B intacta
|
||||
recB := httptest.NewRecorder()
|
||||
reqB := httptest.NewRequest("GET", "/", nil)
|
||||
reqB.Header.Set("X-API-Key", "key-B")
|
||||
handler.ServeHTTP(recB, reqB)
|
||||
if recB.Code != http.StatusOK {
|
||||
t.Errorf("key-B status=%d, want 200", recB.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("key vacia salta el limit", func(t *testing.T) {
|
||||
rl := RateLimiterNew(1, 1)
|
||||
mw := RateLimiterByKey(rl, keyFunc)
|
||||
handler := mw(base)
|
||||
|
||||
// Sin X-API-Key, hagamos muchos requests
|
||||
for i := 0; i < 10; i++ {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("request %d status=%d, want 200 (sin key, sin limit)", i, rec.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("responde 429 con body JSON al exceder", func(t *testing.T) {
|
||||
rl := RateLimiterNew(1, 1)
|
||||
mw := RateLimiterByKey(rl, keyFunc)
|
||||
handler := mw(base)
|
||||
|
||||
// Agotar
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("X-API-Key", "key-X")
|
||||
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)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("body no es JSON: %v", err)
|
||||
}
|
||||
// HTTPError se serializa con campos PascalCase (sin tags JSON)
|
||||
if body["Code"] != "rate_limited" {
|
||||
t.Errorf("Code=%v, want rate_limited", body["Code"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("headers X-RateLimit-* siempre presentes en respuesta", func(t *testing.T) {
|
||||
rl := RateLimiterNew(10, 20)
|
||||
mw := RateLimiterByKey(rl, keyFunc)
|
||||
handler := mw(base)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("X-API-Key", "key-Y")
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Header().Get("X-RateLimit-Limit") == "" {
|
||||
t.Error("X-RateLimit-Limit ausente")
|
||||
}
|
||||
if rec.Header().Get("X-RateLimit-Remaining") == "" {
|
||||
t.Error("X-RateLimit-Remaining ausente")
|
||||
}
|
||||
if rec.Header().Get("X-RateLimit-Reset") == "" {
|
||||
t.Error("X-RateLimit-Reset ausente")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user