Files

201 lines
6.0 KiB
Go

package middleware
import (
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
func TestKeyByIPAndJSONField(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/auth", strings.NewReader(`{"email":" Test@Example.com "}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request.RemoteAddr = "1.2.3.4:5678"
key := KeyByIPAndJSONField("email")(c)
if key != "test@example.com|1.2.3.4" {
t.Fatalf("key want test@example.com|1.2.3.4 got %s", key)
}
body, err := io.ReadAll(c.Request.Body)
if err != nil {
t.Fatalf("read body after key extraction failed: %v", err)
}
if !strings.Contains(string(body), "Test@Example.com") {
t.Fatalf("request body should be restored after reading field")
}
}
func TestRateLimitMiddlewareWithoutClient(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(RateLimitMiddleware(nil, RateLimitRule{WindowSeconds: 60, MaxRequests: 1}, KeyByIP))
r.GET("/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status want 200 got %d", w.Code)
}
if !strings.Contains(w.Body.String(), `"ok":true`) {
t.Fatalf("expected handler response body, got %s", w.Body.String())
}
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/ping", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("second request must be limited even without Redis: status want 429 got %d", w.Code)
}
}
func TestLocalRateLimiterCapacityDoesNotEvictLiveEntries(t *testing.T) {
now := time.Now()
limiter := &localRateLimiter{entries: make(map[string]localRateLimitEntry, localRateLimitMaxEntries)}
for i := 0; i < localRateLimitMaxEntries; i++ {
limiter.entries[fmt.Sprintf("live-%d", i)] = localRateLimitEntry{
count: 1,
expiresAt: now.Add(10 * time.Minute),
}
}
rule := RateLimitRule{WindowSeconds: 60, MaxRequests: 5}
count, _, warned := limiter.increment("new-key", rule, now)
if count <= int64(rule.MaxRequests) {
t.Fatalf("new key must fail closed when local limiter is at capacity, count=%d", count)
}
if !warned {
t.Fatal("first capacity rejection must request an operator warning")
}
_, _, warned = limiter.increment("another-new-key", rule, now.Add(time.Second))
if warned {
t.Fatal("capacity warning must be throttled")
}
_, _, warned = limiter.increment("later-new-key", rule, now.Add(localRateLimitWarningInterval))
if !warned {
t.Fatal("capacity warning should be emitted again after the throttle interval")
}
if len(limiter.entries) != localRateLimitMaxEntries {
t.Fatalf("live entry count changed: got %d want %d", len(limiter.entries), localRateLimitMaxEntries)
}
if _, ok := limiter.entries["live-0"]; !ok {
t.Fatal("capacity handling must not evict live counters")
}
if _, ok := limiter.entries["new-key"]; ok {
t.Fatal("rejected new key must not be stored")
}
}
func TestLocalRateLimiterPurgesExpiredEntriesBeforeRejectingNewKey(t *testing.T) {
now := time.Now()
limiter := &localRateLimiter{entries: make(map[string]localRateLimitEntry, localRateLimitMaxEntries)}
for i := 0; i < localRateLimitMaxEntries; i++ {
expiresAt := now.Add(time.Minute)
if i == 0 {
expiresAt = now.Add(-time.Second)
}
limiter.entries[fmt.Sprintf("entry-%d", i)] = localRateLimitEntry{
count: 1,
expiresAt: expiresAt,
}
}
count, _, warned := limiter.increment("new-key", RateLimitRule{WindowSeconds: 60, MaxRequests: 5}, now)
if count != 1 {
t.Fatalf("new key count = %d, want 1 after expired entry cleanup", count)
}
if warned {
t.Fatal("successful expired-entry cleanup must not emit a capacity warning")
}
if _, ok := limiter.entries["new-key"]; !ok {
t.Fatal("new key should be stored after expired entry cleanup")
}
if _, ok := limiter.entries["entry-0"]; ok {
t.Fatal("expired entry should be removed")
}
}
func TestRateLimitMiddlewareFallsBackLocallyWhenRedisIsUnavailable(t *testing.T) {
gin.SetMode(gin.TestMode)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("reserve local address: %v", err)
}
addr := listener.Addr().String()
if err := listener.Close(); err != nil {
t.Fatalf("close reserved address: %v", err)
}
client := redis.NewClient(&redis.Options{
Addr: addr,
DialTimeout: 50 * time.Millisecond,
ReadTimeout: 50 * time.Millisecond,
WriteTimeout: 50 * time.Millisecond,
MaxRetries: -1,
})
t.Cleanup(func() { _ = client.Close() })
r := gin.New()
r.Use(RateLimitMiddleware(client, RateLimitRule{
Prefix: "redis-fallback-test",
WindowSeconds: 60,
MaxRequests: 1,
}, KeyByIP))
r.GET("/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
first := httptest.NewRecorder()
r.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/ping", nil))
if first.Code != http.StatusOK {
t.Fatalf("first fallback request status = %d, want 200", first.Code)
}
second := httptest.NewRecorder()
r.ServeHTTP(second, httptest.NewRequest(http.MethodGet, "/ping", nil))
if second.Code != http.StatusTooManyRequests {
t.Fatalf("second fallback request status = %d, want 429", second.Code)
}
}
func TestToInt64(t *testing.T) {
cases := []struct {
name string
input interface{}
want int64
ok bool
}{
{name: "int64", input: int64(10), want: 10, ok: true},
{name: "int", input: int(11), want: 11, ok: true},
{name: "uint8", input: uint8(12), want: 12, ok: true},
{name: "float64", input: float64(13.9), want: 13, ok: true},
{name: "string", input: "bad", want: 0, ok: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, ok := toInt64(tc.input)
if ok != tc.ok {
t.Fatalf("ok want %v got %v", tc.ok, ok)
}
if got != tc.want {
t.Fatalf("value want %d got %d", tc.want, got)
}
})
}
}