rdev/internal/ratelimit/ratelimit_test.go
jordan 538ea57ed4 feat: Add claude-config API, security hardening, and testing infrastructure
Claude Config API (v0.6):
- Add CRUD endpoints for commands, skills, and agents
- Commands/skills/agents stored in /workspace/.claude/ (per-project, in git)
- Credentials shared via PVC at /root/.claude/ (shared across pods)
- Use base64 encoding for file writes (prevents shell injection)
- Add content size limits (1MB max)

Security Hardening:
- Add sanitize package for command/prompt validation
- Add rate limiting middleware (token bucket algorithm)
- Add concurrent command limiting
- Add input sanitization to all command handlers
- Gitignore secrets.yaml and credentials.yaml
- Add *.example templates for secrets

Testing Infrastructure:
- Add testutil package with mocks and fixtures
- Add unit tests for auth package (63% coverage)
- Add unit tests for executor (47% coverage)
- Add handler integration tests (40% coverage)
- Add 100% coverage for sanitize, cmdlimit packages
- Add 96% coverage for ratelimit package

Infrastructure:
- Shared Claude credentials PVC (ReadWriteMany)
- Reduced workspace PVC size from 20Gi to 5Gi
- Add init container cleanup before git clone
- Document Longhorn RWX requirements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 01:29:13 -07:00

414 lines
9.2 KiB
Go

package ratelimit
import (
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/orchard9/rdev/internal/auth"
)
func TestNew(t *testing.T) {
t.Run("default config", func(t *testing.T) {
l := New(Config{})
defer l.Stop()
if l.cfg.RequestsPerMinute != 100 {
t.Errorf("RequestsPerMinute = %d, want 100", l.cfg.RequestsPerMinute)
}
if l.cfg.BurstSize != 50 {
t.Errorf("BurstSize = %d, want 50", l.cfg.BurstSize)
}
})
t.Run("custom config", func(t *testing.T) {
l := New(Config{
RequestsPerMinute: 200,
BurstSize: 100,
CleanupInterval: time.Minute,
})
defer l.Stop()
if l.cfg.RequestsPerMinute != 200 {
t.Errorf("RequestsPerMinute = %d, want 200", l.cfg.RequestsPerMinute)
}
if l.cfg.BurstSize != 100 {
t.Errorf("BurstSize = %d, want 100", l.cfg.BurstSize)
}
})
}
func TestDefaultConfig(t *testing.T) {
cfg := DefaultConfig()
if cfg.RequestsPerMinute != 100 {
t.Errorf("RequestsPerMinute = %d, want 100", cfg.RequestsPerMinute)
}
if cfg.BurstSize != 50 {
t.Errorf("BurstSize = %d, want 50", cfg.BurstSize)
}
if cfg.CleanupInterval != 5*time.Minute {
t.Errorf("CleanupInterval = %v, want 5m", cfg.CleanupInterval)
}
}
func TestAllow(t *testing.T) {
t.Run("allows requests within limit", func(t *testing.T) {
l := New(Config{
RequestsPerMinute: 60,
BurstSize: 10,
})
defer l.Stop()
// Should allow burst requests
for i := 0; i < 10; i++ {
remaining, allowed := l.Allow("test-key")
if !allowed {
t.Errorf("Request %d was denied, want allowed", i)
}
if remaining != 10-i-1 {
t.Errorf("Request %d: remaining = %d, want %d", i, remaining, 10-i-1)
}
}
})
t.Run("denies requests exceeding limit", func(t *testing.T) {
l := New(Config{
RequestsPerMinute: 60,
BurstSize: 5,
})
defer l.Stop()
// Exhaust the bucket
for i := 0; i < 5; i++ {
l.Allow("test-key")
}
// Next request should be denied
_, allowed := l.Allow("test-key")
if allowed {
t.Error("Request was allowed, want denied")
}
})
t.Run("refills over time", func(t *testing.T) {
l := New(Config{
RequestsPerMinute: 60, // 1 per second
BurstSize: 1,
})
defer l.Stop()
// Use the one token
l.Allow("test-key")
// Should be denied immediately
_, allowed := l.Allow("test-key")
if allowed {
t.Error("Request was allowed immediately, want denied")
}
// Wait for refill (1 token per second)
time.Sleep(1100 * time.Millisecond)
// Should be allowed now
_, allowed = l.Allow("test-key")
if !allowed {
t.Error("Request was denied after refill, want allowed")
}
})
t.Run("separate buckets per key", func(t *testing.T) {
l := New(Config{
RequestsPerMinute: 60,
BurstSize: 1,
})
defer l.Stop()
// Exhaust key1
l.Allow("key1")
_, allowed1 := l.Allow("key1")
if allowed1 {
t.Error("key1 was allowed, want denied")
}
// key2 should still have tokens
_, allowed2 := l.Allow("key2")
if !allowed2 {
t.Error("key2 was denied, want allowed")
}
})
}
func TestMiddleware(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
t.Run("allows requests within limit", func(t *testing.T) {
l := New(Config{
RequestsPerMinute: 100,
BurstSize: 10,
KeyFunc: KeyFromIP(),
})
defer l.Stop()
middleware := l.Middleware()
wrapped := middleware(handler)
req := httptest.NewRequest("GET", "/test", nil)
req.RemoteAddr = "192.168.1.1:12345"
rec := httptest.NewRecorder()
wrapped.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("Status = %d, want 200", rec.Code)
}
if rec.Header().Get("X-RateLimit-Limit") != "100" {
t.Errorf("X-RateLimit-Limit = %q, want 100", rec.Header().Get("X-RateLimit-Limit"))
}
})
t.Run("returns 429 when rate limited", func(t *testing.T) {
l := New(Config{
RequestsPerMinute: 60,
BurstSize: 1,
KeyFunc: KeyFromIP(),
})
defer l.Stop()
middleware := l.Middleware()
wrapped := middleware(handler)
// First request should succeed
req1 := httptest.NewRequest("GET", "/test", nil)
req1.RemoteAddr = "192.168.1.1:12345"
rec1 := httptest.NewRecorder()
wrapped.ServeHTTP(rec1, req1)
if rec1.Code != http.StatusOK {
t.Errorf("First request status = %d, want 200", rec1.Code)
}
// Second request should be rate limited
req2 := httptest.NewRequest("GET", "/test", nil)
req2.RemoteAddr = "192.168.1.1:12345"
rec2 := httptest.NewRecorder()
wrapped.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusTooManyRequests {
t.Errorf("Second request status = %d, want 429", rec2.Code)
}
if rec2.Header().Get("Retry-After") == "" {
t.Error("Retry-After header not set")
}
})
t.Run("no key means no rate limiting", func(t *testing.T) {
l := New(Config{
RequestsPerMinute: 60,
BurstSize: 1,
KeyFunc: func(r *http.Request) string {
return "" // No key
},
})
defer l.Stop()
middleware := l.Middleware()
wrapped := middleware(handler)
// Multiple requests should all succeed
for i := 0; i < 5; i++ {
req := httptest.NewRequest("GET", "/test", nil)
rec := httptest.NewRecorder()
wrapped.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("Request %d status = %d, want 200", i, rec.Code)
}
}
})
}
func TestGetClientIP(t *testing.T) {
tests := []struct {
name string
remoteAddr string
xff string
xri string
want string
}{
{"from RemoteAddr", "192.168.1.1:12345", "", "", "192.168.1.1"},
{"from X-Forwarded-For single", "127.0.0.1:8080", "10.0.0.1", "", "10.0.0.1"},
{"from X-Forwarded-For multiple", "127.0.0.1:8080", "10.0.0.1, 10.0.0.2", "", "10.0.0.1"},
{"from X-Real-IP", "127.0.0.1:8080", "", "10.0.0.5", "10.0.0.5"},
{"X-Forwarded-For takes precedence", "127.0.0.1:8080", "10.0.0.1", "10.0.0.5", "10.0.0.1"},
{"no port in RemoteAddr", "192.168.1.1", "", "", "192.168.1.1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = tt.remoteAddr
if tt.xff != "" {
req.Header.Set("X-Forwarded-For", tt.xff)
}
if tt.xri != "" {
req.Header.Set("X-Real-IP", tt.xri)
}
got := getClientIP(req)
if got != tt.want {
t.Errorf("getClientIP() = %q, want %q", got, tt.want)
}
})
}
}
func TestKeyFromAPIKey(t *testing.T) {
keyFunc := KeyFromAPIKey()
t.Run("extracts from context", func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
apiKey := &auth.APIKey{ID: "test-key-123"}
ctx := auth.WithAPIKey(req.Context(), apiKey)
req = req.WithContext(ctx)
got := keyFunc(req)
if got != "test-key-123" {
t.Errorf("KeyFromAPIKey() = %q, want test-key-123", got)
}
})
t.Run("falls back to IP", func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = "192.168.1.100:12345"
got := keyFunc(req)
if got != "192.168.1.100" {
t.Errorf("KeyFromAPIKey() = %q, want 192.168.1.100", got)
}
})
}
func TestKeyFromIP(t *testing.T) {
keyFunc := KeyFromIP()
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = "10.0.0.50:12345"
got := keyFunc(req)
if got != "10.0.0.50" {
t.Errorf("KeyFromIP() = %q, want 10.0.0.50", got)
}
}
func TestItoa(t *testing.T) {
tests := []struct {
input int
want string
}{
{0, "0"},
{1, "1"},
{10, "10"},
{100, "100"},
{12345, "12345"},
{-1, "-1"},
{-12345, "-12345"},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
got := itoa(tt.input)
if got != tt.want {
t.Errorf("itoa(%d) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestConcurrentAccess(t *testing.T) {
l := New(Config{
RequestsPerMinute: 1000,
BurstSize: 100,
})
defer l.Stop()
var wg sync.WaitGroup
var allowedCount, deniedCount int64
var mu sync.Mutex
// Spawn many goroutines making requests
for i := 0; i < 200; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, allowed := l.Allow("concurrent-test")
mu.Lock()
if allowed {
allowedCount++
} else {
deniedCount++
}
mu.Unlock()
}()
}
wg.Wait()
// Should have allowed approximately BurstSize requests
// and denied the rest
if allowedCount < 90 || allowedCount > 110 {
t.Errorf("allowedCount = %d, want ~100", allowedCount)
}
if deniedCount < 90 || deniedCount > 110 {
t.Errorf("deniedCount = %d, want ~100", deniedCount)
}
}
func TestCleanup(t *testing.T) {
l := New(Config{
RequestsPerMinute: 60,
BurstSize: 10,
CleanupInterval: 50 * time.Millisecond,
})
defer l.Stop()
// Make some requests to create buckets
l.Allow("key1")
l.Allow("key2")
l.mu.RLock()
bucketCount := len(l.buckets)
l.mu.RUnlock()
if bucketCount != 2 {
t.Errorf("bucketCount = %d, want 2", bucketCount)
}
// Wait for cleanup (2x cleanup interval)
time.Sleep(150 * time.Millisecond)
l.mu.RLock()
bucketCount = len(l.buckets)
l.mu.RUnlock()
// Buckets should be cleaned up
if bucketCount != 0 {
t.Errorf("bucketCount after cleanup = %d, want 0", bucketCount)
}
}
func TestStop(t *testing.T) {
l := New(Config{})
// Should not panic when stopping
l.Stop()
// Should not panic if stopped multiple times
// (but this is technically undefined behavior - just testing it doesn't crash)
}