201 lines
6.0 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|