packages.wenpai.net/internal/http/api_rate_limit_test.go
Ben Word db40af2ea8
Add GET /api/stats endpoint for total downloads badge (#53)
* Add GET /api/stats endpoint for total downloads badge

Public JSON endpoint that reads the existing package_stats singleton
row (same data source as the admin dashboard). Includes per-IP rate
limiting (10 req/min) and Cache-Control: public, max-age=300 so
CDN/shields.io caching reduces actual hits further.

Response shape:
  { total_installs, installs_30d, active_plugins, active_themes, total_packages }

https://claude.ai/code/session_019NzdEnnohsqdqyuw5VXUgy

* Use golang.org/x/time/rate for API rate limiting and add installs badge

Replace custom token-counting rate limiter with per-IP rate.Limiter
instances from golang.org/x/time/rate. Add total composer installs
badge to README.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Strip port from RemoteAddr before rate limiting

Use clientIP to normalize host:port to just the host, so varying
source ports from the same IP share a single rate limiter. Update
middleware test to use realistic host:port addresses.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Show exact install counts with per-type breakdown on admin dashboard

- Display exact numbers with commas instead of rounded "24K"
- Show plugin and theme install counts separately
- Show timestamp in CST instead of relative time

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-23 15:08:31 -05:00

115 lines
2.7 KiB
Go

package http
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
func TestAPIRateLimiter_AllowsUnderLimit(t *testing.T) {
l := newAPIRateLimiter()
for i := 0; i < apiRateLimitPerIP; i++ {
if !l.limiterFor("1.2.3.4").Allow() {
t.Fatalf("request %d should be allowed", i+1)
}
}
}
func TestAPIRateLimiter_BlocksOverLimit(t *testing.T) {
l := newAPIRateLimiter()
for i := 0; i < apiRateLimitPerIP; i++ {
l.limiterFor("1.2.3.4").Allow()
}
if l.limiterFor("1.2.3.4").Allow() {
t.Error("request over limit should be blocked")
}
}
func TestAPIRateLimiter_IndependentPerIP(t *testing.T) {
l := newAPIRateLimiter()
// Exhaust limit for one IP
for i := 0; i < apiRateLimitPerIP; i++ {
l.limiterFor("1.2.3.4").Allow()
}
// Different IP should still be allowed
if !l.limiterFor("5.6.7.8").Allow() {
t.Error("different IP should not be affected")
}
}
func TestAPIRateLimiter_EmptyIPAllowed(t *testing.T) {
l := newAPIRateLimiter()
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := l.RateLimit(inner)
req := httptest.NewRequest("GET", "/api/stats", nil)
req.RemoteAddr = ""
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("empty IP should always be allowed, got %d", w.Code)
}
}
func TestAPIRateLimiter_Middleware(t *testing.T) {
l := newAPIRateLimiter()
called := 0
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called++
w.WriteHeader(http.StatusOK)
})
handler := l.RateLimit(inner)
// Use varying source ports to verify the middleware normalizes to IP only.
for i := 0; i < apiRateLimitPerIP+2; i++ {
req := httptest.NewRequest("GET", "/api/stats", nil)
req.RemoteAddr = fmt.Sprintf("10.0.0.1:%d", 10000+i)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if i < apiRateLimitPerIP {
if w.Code != http.StatusOK {
t.Fatalf("request %d: got %d, want 200", i+1, w.Code)
}
} else {
if w.Code != http.StatusTooManyRequests {
t.Fatalf("request %d: got %d, want 429", i+1, w.Code)
}
}
}
if called != apiRateLimitPerIP {
t.Errorf("inner handler called %d times, want %d", called, apiRateLimitPerIP)
}
}
func TestAPIRateLimiter_Sweep(t *testing.T) {
l := newAPIRateLimiter()
l.limiterFor("1.2.3.4")
// Manually set lastSeen far in the past to trigger sweep
l.mu.Lock()
l.limiters["1.2.3.4"].lastSeen = l.limiters["1.2.3.4"].lastSeen.Add(-(apiRateLimiterSweepEvery + apiRateLimitWindow*2 + 1))
l.lastCleanup = l.limiters["1.2.3.4"].lastSeen
l.mu.Unlock()
l.limiterFor("5.6.7.8") // triggers sweep
l.mu.Lock()
_, exists := l.limiters["1.2.3.4"]
l.mu.Unlock()
if exists {
t.Error("stale entry should have been swept")
}
}