2820823b36
Add an optional API-key layer to the public read-only API. Keys grant higher per-minute rate limits and attribute usage; anonymous callers are still allowed at a lower IP-based budget. - migration 0008_api_key: api_key table (sha256 hash only, plaintext shown once) - apikey pkg: key generation + hashing - ratelimit pkg: Redis fixed-window limiter + per-key usage counters; fails open - public API middleware: X-API-Key / Bearer auth, X-RateLimit-* headers, 429+Retry-After - admin: issue/list/revoke keys + usage view (API + UI tab) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
95 lines
2.5 KiB
Go
95 lines
2.5 KiB
Go
package ratelimit
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestDisabledFailsOpen verifies that a Limiter without a Redis backend allows
|
|
// all requests and reports usage as zero rather than erroring.
|
|
func TestDisabledFailsOpen(t *testing.T) {
|
|
l := New("not-a-valid-url") // parse error => disabled
|
|
if l.Enabled() {
|
|
t.Fatal("expected limiter to be disabled for invalid url")
|
|
}
|
|
res := l.Allow(context.Background(), "x", 1, time.Minute)
|
|
if !res.Allowed || res.Remaining != 1 {
|
|
t.Fatalf("disabled limiter must fail open: %+v", res)
|
|
}
|
|
// Must not panic and must return zero usage.
|
|
l.RecordUsage(context.Background(), "k1")
|
|
if u := l.Usage(context.Background(), "k1"); u.Total != 0 {
|
|
t.Fatalf("disabled usage should be zero, got %+v", u)
|
|
}
|
|
}
|
|
|
|
// TestNilReceiverSafe ensures a nil *Limiter is safe to use (handler default).
|
|
func TestNilReceiverSafe(t *testing.T) {
|
|
var l *Limiter
|
|
if l.Enabled() {
|
|
t.Fatal("nil limiter must report disabled")
|
|
}
|
|
res := l.Allow(context.Background(), "x", 5, time.Minute)
|
|
if !res.Allowed {
|
|
t.Fatal("nil limiter must fail open")
|
|
}
|
|
l.RecordUsage(context.Background(), "k")
|
|
_ = l.Usage(context.Background(), "k")
|
|
}
|
|
|
|
func testLimiter(t *testing.T) *Limiter {
|
|
t.Helper()
|
|
url := os.Getenv("OPENGOODS_REDIS_URL")
|
|
if url == "" {
|
|
url = "redis://localhost:6379/0"
|
|
}
|
|
l := New(url)
|
|
if !l.Enabled() {
|
|
t.Skip("redis not configured")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := l.rdb.Ping(ctx).Err(); err != nil {
|
|
t.Skipf("redis not reachable: %v", err)
|
|
}
|
|
return l
|
|
}
|
|
|
|
func TestAllowFixedWindow(t *testing.T) {
|
|
l := testLimiter(t)
|
|
ctx := context.Background()
|
|
id := fmt.Sprintf("test:%d", time.Now().UnixNano())
|
|
|
|
for i := 1; i <= 2; i++ {
|
|
if res := l.Allow(ctx, id, 2, time.Minute); !res.Allowed {
|
|
t.Fatalf("request %d should be allowed: %+v", i, res)
|
|
}
|
|
}
|
|
res := l.Allow(ctx, id, 2, time.Minute)
|
|
if res.Allowed {
|
|
t.Fatalf("3rd request over limit 2 should be denied: %+v", res)
|
|
}
|
|
if res.Remaining != 0 {
|
|
t.Fatalf("remaining should be 0 when over limit, got %d", res.Remaining)
|
|
}
|
|
}
|
|
|
|
func TestRecordAndReadUsage(t *testing.T) {
|
|
l := testLimiter(t)
|
|
ctx := context.Background()
|
|
key := fmt.Sprintf("usagekey:%d", time.Now().UnixNano())
|
|
|
|
l.RecordUsage(ctx, key)
|
|
l.RecordUsage(ctx, key)
|
|
u := l.Usage(ctx, key)
|
|
if u.Total != 2 || u.Today != 2 {
|
|
t.Fatalf("expected total=2 today=2, got %+v", u)
|
|
}
|
|
if u.LastUsedAt == nil {
|
|
t.Fatal("expected last-used timestamp to be set")
|
|
}
|
|
}
|