mirror of
https://github.com/lightninglabs/aperture.git
synced 2026-01-31 15:14:26 +01:00
437 lines
12 KiB
Go
437 lines
12 KiB
Go
package proxy
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"regexp"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// TestRateLimiterBasic tests basic rate limiting functionality.
|
|
func TestRateLimiterBasic(t *testing.T) {
|
|
cfg := &RateLimitConfig{
|
|
PathRegexp: "^/api/.*$",
|
|
Requests: 10,
|
|
Per: time.Second,
|
|
Burst: 10,
|
|
}
|
|
cfg.compiledPathRegexp = regexp.MustCompile(cfg.PathRegexp)
|
|
|
|
rl := NewRateLimiter("test-service", []*RateLimitConfig{cfg})
|
|
|
|
// First 10 requests should be allowed.
|
|
for i := 0; i < 10; i++ {
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
allowed, _ := rl.Allow(req, "test-key")
|
|
require.True(t, allowed, "request %d should be allowed", i)
|
|
}
|
|
|
|
// 11th request should be denied.
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
allowed, retryAfter := rl.Allow(req, "test-key")
|
|
require.False(t, allowed, "11th request should be denied")
|
|
require.Greater(t, retryAfter, time.Duration(0))
|
|
}
|
|
|
|
// TestRateLimiterNoMatchingRules tests that requests pass when no rules match.
|
|
func TestRateLimiterNoMatchingRules(t *testing.T) {
|
|
cfg := &RateLimitConfig{
|
|
PathRegexp: "^/api/.*$",
|
|
Requests: 1,
|
|
Per: time.Hour,
|
|
Burst: 1,
|
|
}
|
|
cfg.compiledPathRegexp = regexp.MustCompile(cfg.PathRegexp)
|
|
|
|
rl := NewRateLimiter("test-service", []*RateLimitConfig{cfg})
|
|
|
|
// Request to non-matching path should always be allowed.
|
|
for i := 0; i < 100; i++ {
|
|
req := httptest.NewRequest("GET", "/other/path", nil)
|
|
allowed, _ := rl.Allow(req, "test-key")
|
|
require.True(t, allowed, "non-matching request should be allowed")
|
|
}
|
|
}
|
|
|
|
// TestRateLimiterLRUEviction tests that the LRU cache evicts old entries.
|
|
func TestRateLimiterLRUEviction(t *testing.T) {
|
|
cfg := &RateLimitConfig{
|
|
Requests: 100,
|
|
Per: time.Second,
|
|
Burst: 100,
|
|
}
|
|
|
|
rl := NewRateLimiter(
|
|
"test-service", []*RateLimitConfig{cfg},
|
|
WithMaxCacheSize(5),
|
|
)
|
|
|
|
// Create 10 different keys.
|
|
for i := 0; i < 10; i++ {
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
key := fmt.Sprintf("key-%d", i)
|
|
rl.Allow(req, key)
|
|
}
|
|
|
|
// Cache should be at max size.
|
|
require.Equal(t, 5, rl.Size())
|
|
}
|
|
|
|
// TestRateLimiterPathMatching tests that different path patterns have
|
|
// independent limits.
|
|
func TestRateLimiterPathMatching(t *testing.T) {
|
|
cfgApi := &RateLimitConfig{
|
|
PathRegexp: "^/api/.*$",
|
|
Requests: 5,
|
|
Per: time.Second,
|
|
Burst: 5,
|
|
}
|
|
cfgApi.compiledPathRegexp = regexp.MustCompile(cfgApi.PathRegexp)
|
|
|
|
cfgAdmin := &RateLimitConfig{
|
|
PathRegexp: "^/admin/.*$",
|
|
Requests: 2,
|
|
Per: time.Second,
|
|
Burst: 2,
|
|
}
|
|
cfgAdmin.compiledPathRegexp = regexp.MustCompile(cfgAdmin.PathRegexp)
|
|
|
|
rl := NewRateLimiter(
|
|
"test-service",
|
|
[]*RateLimitConfig{cfgApi, cfgAdmin},
|
|
)
|
|
|
|
// API path should allow 5 requests.
|
|
for i := 0; i < 5; i++ {
|
|
req := httptest.NewRequest("GET", "/api/users", nil)
|
|
allowed, _ := rl.Allow(req, "test-key")
|
|
require.True(t, allowed)
|
|
}
|
|
|
|
// Admin path should allow 2 requests.
|
|
for i := 0; i < 2; i++ {
|
|
req := httptest.NewRequest("GET", "/admin/settings", nil)
|
|
allowed, _ := rl.Allow(req, "test-key")
|
|
require.True(t, allowed)
|
|
}
|
|
|
|
// Next admin request should be denied.
|
|
req := httptest.NewRequest("GET", "/admin/settings", nil)
|
|
allowed, _ := rl.Allow(req, "test-key")
|
|
require.False(t, allowed)
|
|
|
|
// API should still have capacity (used 5, burst is 5, but we're testing
|
|
// a 6th).
|
|
req = httptest.NewRequest("GET", "/api/users", nil)
|
|
allowed, _ = rl.Allow(req, "test-key")
|
|
require.False(t, allowed, "6th API request should be denied")
|
|
}
|
|
|
|
// TestRateLimiterMultipleRulesAllMustPass tests that all matching rules must
|
|
// pass for a request to be allowed.
|
|
func TestRateLimiterMultipleRulesAllMustPass(t *testing.T) {
|
|
// Global rule: 100 req/sec.
|
|
cfgGlobal := &RateLimitConfig{
|
|
Requests: 100,
|
|
Per: time.Second,
|
|
Burst: 100,
|
|
}
|
|
|
|
// Specific rule: 2 req/sec for /expensive.
|
|
cfgExpensive := &RateLimitConfig{
|
|
PathRegexp: "^/expensive$",
|
|
Requests: 2,
|
|
Per: time.Second,
|
|
Burst: 2,
|
|
}
|
|
cfgExpensive.compiledPathRegexp = regexp.MustCompile(cfgExpensive.PathRegexp)
|
|
|
|
rl := NewRateLimiter(
|
|
"test-service",
|
|
[]*RateLimitConfig{cfgGlobal, cfgExpensive},
|
|
)
|
|
|
|
// Expensive should be limited by the stricter rule.
|
|
for i := 0; i < 2; i++ {
|
|
req := httptest.NewRequest("GET", "/expensive", nil)
|
|
allowed, _ := rl.Allow(req, "test-key")
|
|
require.True(t, allowed)
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/expensive", nil)
|
|
allowed, _ := rl.Allow(req, "test-key")
|
|
require.False(t, allowed, "should be denied by /expensive rule")
|
|
}
|
|
|
|
// TestRateLimiterPerKeyIsolation tests that different keys have independent
|
|
// rate limits.
|
|
func TestRateLimiterPerKeyIsolation(t *testing.T) {
|
|
cfg := &RateLimitConfig{
|
|
Requests: 2,
|
|
Per: time.Second,
|
|
Burst: 2,
|
|
}
|
|
|
|
rl := NewRateLimiter("test-service", []*RateLimitConfig{cfg})
|
|
|
|
// User 1 uses their quota.
|
|
for i := 0; i < 2; i++ {
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
allowed, _ := rl.Allow(req, "user-1")
|
|
require.True(t, allowed)
|
|
}
|
|
|
|
// User 1 is now denied.
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
allowed, _ := rl.Allow(req, "user-1")
|
|
require.False(t, allowed)
|
|
|
|
// User 2 should still have full quota.
|
|
for i := 0; i < 2; i++ {
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
allowed, _ := rl.Allow(req, "user-2")
|
|
require.True(t, allowed)
|
|
}
|
|
}
|
|
|
|
// TestExtractRateLimitKeyIP tests IP-based key extraction for unauthenticated
|
|
// requests.
|
|
func TestExtractRateLimitKeyIP(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
ip := net.ParseIP("192.168.1.100")
|
|
|
|
// Unauthenticated request should use masked IP (/24 for IPv4).
|
|
key := ExtractRateLimitKey(req, ip, false)
|
|
require.Equal(t, "ip:192.168.1.0", key)
|
|
}
|
|
|
|
// TestExtractRateLimitKeyIPv6 tests IPv6 key extraction.
|
|
func TestExtractRateLimitKeyIPv6(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
ip := net.ParseIP("2001:db8:1234:5678::1")
|
|
|
|
// IPv6 should be masked to /48.
|
|
key := ExtractRateLimitKey(req, ip, false)
|
|
require.Equal(t, "ip:2001:db8:1234::", key)
|
|
}
|
|
|
|
// TestExtractRateLimitKeyUnauthenticatedIgnoresL402 tests that unauthenticated
|
|
// requests fall back to IP even if L402 header is present. This prevents DoS
|
|
// attacks where garbage L402 tokens flood the cache.
|
|
func TestExtractRateLimitKeyUnauthenticatedIgnoresL402(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
// Add a garbage L402 header that would be present before authentication.
|
|
req.Header.Set("Authorization", "L402 garbage:token")
|
|
ip := net.ParseIP("192.168.1.100")
|
|
|
|
// Even with L402 header present, unauthenticated=false should use
|
|
// masked IP.
|
|
key := ExtractRateLimitKey(req, ip, false)
|
|
require.Equal(t, "ip:192.168.1.0", key)
|
|
}
|
|
|
|
// TestRateLimitConfigRate tests the Rate() calculation.
|
|
func TestRateLimitConfigRate(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
requests int
|
|
per time.Duration
|
|
wantRate float64
|
|
}{
|
|
{
|
|
name: "10 per second",
|
|
requests: 10,
|
|
per: time.Second,
|
|
wantRate: 10.0,
|
|
},
|
|
{
|
|
name: "60 per minute",
|
|
requests: 60,
|
|
per: time.Minute,
|
|
wantRate: 1.0,
|
|
},
|
|
{
|
|
name: "1 per hour",
|
|
requests: 1,
|
|
per: time.Hour,
|
|
wantRate: 1.0 / 3600.0,
|
|
},
|
|
{
|
|
name: "zero per",
|
|
requests: 10,
|
|
per: 0,
|
|
wantRate: 0,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg := &RateLimitConfig{
|
|
Requests: tt.requests,
|
|
Per: tt.per,
|
|
}
|
|
require.InDelta(t, tt.wantRate, cfg.Rate(), 0.0001)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRateLimitConfigEffectiveBurst tests the EffectiveBurst() calculation.
|
|
func TestRateLimitConfigEffectiveBurst(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
requests int
|
|
burst int
|
|
wantBurst int
|
|
}{
|
|
{
|
|
name: "explicit burst",
|
|
requests: 10,
|
|
burst: 20,
|
|
wantBurst: 20,
|
|
},
|
|
{
|
|
name: "default to requests",
|
|
requests: 10,
|
|
burst: 0,
|
|
wantBurst: 10,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg := &RateLimitConfig{
|
|
Requests: tt.requests,
|
|
Burst: tt.burst,
|
|
}
|
|
require.Equal(t, tt.wantBurst, cfg.EffectiveBurst())
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRateLimitConfigMatches tests the Matches() method.
|
|
func TestRateLimitConfigMatches(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
pathRegexp string
|
|
path string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "no pattern matches all",
|
|
pathRegexp: "",
|
|
path: "/anything",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "pattern matches",
|
|
pathRegexp: "^/api/.*$",
|
|
path: "/api/users",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "pattern does not match",
|
|
pathRegexp: "^/api/.*$",
|
|
path: "/admin/users",
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg := &RateLimitConfig{
|
|
PathRegexp: tt.pathRegexp,
|
|
}
|
|
if tt.pathRegexp != "" {
|
|
cfg.compiledPathRegexp = regexp.MustCompile(
|
|
tt.pathRegexp,
|
|
)
|
|
}
|
|
require.Equal(t, tt.want, cfg.Matches(tt.path))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSendRateLimitResponseHTTP tests HTTP rate limit response.
|
|
func TestSendRateLimitResponseHTTP(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
|
|
sendRateLimitResponse(w, req, 5*time.Second)
|
|
|
|
require.Equal(t, http.StatusTooManyRequests, w.Code)
|
|
require.Equal(t, "5", w.Header().Get("Retry-After"))
|
|
require.Contains(t, w.Body.String(), "rate limit exceeded")
|
|
}
|
|
|
|
// TestSendRateLimitResponseHTTPSubSecond tests that sub-second delays are
|
|
// rounded up to 1 second.
|
|
func TestSendRateLimitResponseHTTPSubSecond(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
|
|
sendRateLimitResponse(w, req, 500*time.Millisecond)
|
|
|
|
require.Equal(t, http.StatusTooManyRequests, w.Code)
|
|
require.Equal(t, "1", w.Header().Get("Retry-After"))
|
|
}
|
|
|
|
// TestSendRateLimitResponseHTTPRoundUp tests that fractional seconds are
|
|
// rounded up, not down. This ensures clients don't retry before the limit
|
|
// actually resets.
|
|
func TestSendRateLimitResponseHTTPRoundUp(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
|
|
// 1.1 seconds should round up to 2 seconds, not down to 1.
|
|
sendRateLimitResponse(w, req, 1100*time.Millisecond)
|
|
|
|
require.Equal(t, http.StatusTooManyRequests, w.Code)
|
|
require.Equal(t, "2", w.Header().Get("Retry-After"))
|
|
}
|
|
|
|
// TestSendRateLimitResponseGRPC tests gRPC rate limit response.
|
|
func TestSendRateLimitResponseGRPC(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest("POST", "/grpc.Service/Method", nil)
|
|
req.Header.Set("Content-Type", "application/grpc")
|
|
|
|
sendRateLimitResponse(w, req, 5*time.Second)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code) // gRPC always returns 200.
|
|
require.Equal(t, "5", w.Header().Get("Retry-After"))
|
|
require.Equal(t, "8", w.Header().Get("Grpc-Status")) // ResourceExhausted.
|
|
require.Equal(t, "rate limit exceeded", w.Header().Get("Grpc-Message"))
|
|
}
|
|
|
|
// TestRateLimiterTokenRefill tests that tokens refill over time.
|
|
func TestRateLimiterTokenRefill(t *testing.T) {
|
|
cfg := &RateLimitConfig{
|
|
Requests: 10,
|
|
Per: 100 * time.Millisecond, // Fast refill for testing.
|
|
Burst: 1,
|
|
}
|
|
|
|
rl := NewRateLimiter("test-service", []*RateLimitConfig{cfg})
|
|
|
|
// Use the one available token.
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
allowed, _ := rl.Allow(req, "test-key")
|
|
require.True(t, allowed)
|
|
|
|
// Immediate second request should be denied.
|
|
allowed, _ = rl.Allow(req, "test-key")
|
|
require.False(t, allowed)
|
|
|
|
// Wait for refill.
|
|
time.Sleep(15 * time.Millisecond)
|
|
|
|
// Should have a token now.
|
|
allowed, _ = rl.Allow(req, "test-key")
|
|
require.True(t, allowed)
|
|
}
|