REVIEW-001 (M0) M3s: the fixed-window limiter's state map was never pruned, so a flood of distinct keys grew it without bound (memory DoS). Sweep elapsed windows on insert — gated on a size threshold and at most once per window, so it stays amortised — and hard-cap the map (65536 entries) as the backstop. Expose Len() for tests/observability and cover both the sweep and the cap.
169 lines
5 KiB
Go
169 lines
5 KiB
Go
package middleware_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/middleware"
|
|
)
|
|
|
|
func TestNewCorrelationID(t *testing.T) {
|
|
a := middleware.NewCorrelationID()
|
|
b := middleware.NewCorrelationID()
|
|
if a == "" || a == b {
|
|
t.Fatalf("correlation ids are empty or identical: %q, %q", a, b)
|
|
}
|
|
if len(a) != 36 || a[8] != '-' || a[13] != '-' || a[18] != '-' || a[23] != '-' {
|
|
t.Errorf("correlation id %q is not UUID-shaped", a)
|
|
}
|
|
}
|
|
|
|
func TestCorrelationIDMiddleware(t *testing.T) {
|
|
var seen string
|
|
h := middleware.CorrelationID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
seen = middleware.CorrelationFrom(r.Context())
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest("GET", "/home", nil))
|
|
|
|
header := rec.Header().Get(middleware.CorrelationHeader)
|
|
if header == "" {
|
|
t.Fatal("response is missing the correlation header")
|
|
}
|
|
if seen != header {
|
|
t.Errorf("context correlation = %q, header = %q; want equal", seen, header)
|
|
}
|
|
}
|
|
|
|
func TestCorrelationRoundTrip(t *testing.T) {
|
|
if got := middleware.CorrelationFrom(context.Background()); got != "" {
|
|
t.Errorf("CorrelationFrom(background) = %q, want empty", got)
|
|
}
|
|
ctx := middleware.WithCorrelation(context.Background(), "corr-1")
|
|
if got := middleware.CorrelationFrom(ctx); got != "corr-1" {
|
|
t.Errorf("CorrelationFrom = %q, want corr-1", got)
|
|
}
|
|
}
|
|
|
|
func TestRateLimiterAllow(t *testing.T) {
|
|
l := middleware.NewRateLimiter(2, time.Minute)
|
|
if !l.Allow("k") || !l.Allow("k") {
|
|
t.Fatal("the first two requests should be allowed")
|
|
}
|
|
if l.Allow("k") {
|
|
t.Error("the third request should be denied")
|
|
}
|
|
if !l.Allow("other") {
|
|
t.Error("a different key must have its own bucket")
|
|
}
|
|
}
|
|
|
|
func TestRateLimiterWindowResets(t *testing.T) {
|
|
l := middleware.NewRateLimiter(1, time.Minute)
|
|
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
l.SetClock(func() time.Time { return now })
|
|
|
|
if !l.Allow("k") || l.Allow("k") {
|
|
t.Fatal("limit of 1 not enforced")
|
|
}
|
|
now = now.Add(2 * time.Minute)
|
|
if !l.Allow("k") {
|
|
t.Error("the window did not reset")
|
|
}
|
|
}
|
|
|
|
// TestRateLimiterPrunesElapsedWindows covers M3s: the state map must not grow
|
|
// without bound. Once enough distinct keys have been seen, an elapsed window
|
|
// makes the next insert sweep them away.
|
|
func TestRateLimiterPrunesElapsedWindows(t *testing.T) {
|
|
l := middleware.NewRateLimiter(1, time.Minute)
|
|
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
l.SetClock(func() time.Time { return now })
|
|
|
|
// Seed enough distinct keys to make a sweep worthwhile.
|
|
const seeded = 5000
|
|
for i := 0; i < seeded; i++ {
|
|
l.Allow("k" + strconv.Itoa(i))
|
|
}
|
|
if got := l.Len(); got != seeded {
|
|
t.Fatalf("Len = %d, want %d live windows", got, seeded)
|
|
}
|
|
|
|
// Two windows later every seeded window has elapsed; the next new key
|
|
// triggers the sweep.
|
|
now = now.Add(2 * time.Minute)
|
|
l.Allow("fresh")
|
|
|
|
if got := l.Len(); got > 10 {
|
|
t.Errorf("Len after the sweeps = %d, want the %d elapsed windows pruned (M3s)", got, seeded)
|
|
}
|
|
}
|
|
|
|
// TestRateLimiterMapIsCapped covers M3s's hard cap: a flood of distinct keys
|
|
// must not grow the map without bound.
|
|
func TestRateLimiterMapIsCapped(t *testing.T) {
|
|
l := middleware.NewRateLimiter(1, time.Minute)
|
|
// The clock is frozen, so the time-gated sweep cannot fire — only the hard
|
|
// cap bounds the map.
|
|
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
l.SetClock(func() time.Time { return now })
|
|
|
|
const inserted = 70_000
|
|
for i := 0; i < inserted; i++ {
|
|
l.Allow("k" + strconv.Itoa(i))
|
|
}
|
|
if got := l.Len(); got >= inserted {
|
|
t.Errorf("the limiter held %d keys after %d inserts — the map is unbounded (M3s)", got, inserted)
|
|
}
|
|
}
|
|
|
|
func TestRateLimiterMiddleware429(t *testing.T) {
|
|
l := middleware.NewRateLimiter(1, time.Minute)
|
|
h := l.Middleware(func(*http.Request) string { return "k" },
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
|
|
|
|
rec1 := httptest.NewRecorder()
|
|
h.ServeHTTP(rec1, httptest.NewRequest("GET", "/access", nil))
|
|
if rec1.Code != http.StatusOK {
|
|
t.Fatalf("first request = %d, want 200", rec1.Code)
|
|
}
|
|
|
|
rec2 := httptest.NewRecorder()
|
|
h.ServeHTTP(rec2, httptest.NewRequest("GET", "/access", nil))
|
|
if rec2.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("second request = %d, want 429", rec2.Code)
|
|
}
|
|
if rec2.Header().Get("Retry-After") == "" {
|
|
t.Error("429 is missing a Retry-After header")
|
|
}
|
|
}
|
|
|
|
func TestClassAndAuthSensitive(t *testing.T) {
|
|
cases := []struct {
|
|
path string
|
|
want string
|
|
}{
|
|
{"/", ""},
|
|
{"/home", ""},
|
|
{"/api/v1", ""},
|
|
{"/static/x.css", ""},
|
|
{"/access", "access"},
|
|
{"/setup", "setup"},
|
|
{"/AbCdEf0123456789", "guid"},
|
|
{"/AbCdEf0123456789/home", "guid"},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := middleware.Class(tc.path); got != tc.want {
|
|
t.Errorf("Class(%q) = %q, want %q", tc.path, got, tc.want)
|
|
}
|
|
if got := middleware.AuthSensitive(tc.path); got != (tc.want != "") {
|
|
t.Errorf("AuthSensitive(%q) = %v, want %v", tc.path, got, tc.want != "")
|
|
}
|
|
}
|
|
}
|