orama/core/pkg/ratelimit/manager_test.go
anonpenguin23 fda47533c3 feat: per-namespace rate-limit self-service + WS JWT auth + release 0.122.12
Per-namespace rate-limit config (feature #69)
- Migration 027: new `namespace_rate_limit_config` table
  (namespace PK, requests_per_minute, burst, audit metadata).
- pkg/ratelimit: Manager + RQLite ConfigStore + types. Same pattern
  as the push config in bug #220's follow-up — LRU cache, invalidate
  on PUT/DELETE, falls back to YAML defaults when no row exists.
- pkg/gateway/handlers/ratelimit: GET/PUT/DELETE /v1/namespace/rate-limit.
  PUT requests are rejected if they exceed the operator's configured
  ceiling (MaxRequestsPerMinute / MaxBurst) — tenants self-serve but
  cannot raise their quota past the cap.
- pkg/gateway/rate_limiter.go: per-namespace lookup, default fallback.
- pkg/gateway/middleware.go: WS JWT middleware (middleware_ws_jwt_test.go).
- pkg/gateway/auth/service.go: refresh-token rotation hardening with
  regression test in refresh_rotation_test.go.

AI agent instructions
- Add AGENTS.md, CLAUDE.md, .github/copilot-instructions.md (DeBros v0.2.0
  baseline).

DeBros rules bumped to v0.2.0 (sha bb6e6ef).

VERSION bumped to 0.122.12.
2026-05-13 15:41:36 +03:00

243 lines
7.7 KiB
Go

package ratelimit
import (
"context"
"sync"
"testing"
)
// memStore is an in-memory ConfigStore for tests.
type memStore struct {
mu sync.Mutex
rows map[string]Config
getErr error
}
func newMemStore() *memStore { return &memStore{rows: map[string]Config{}} }
func (m *memStore) Get(_ context.Context, namespace string) (*Config, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.getErr != nil {
return nil, m.getErr
}
if c, ok := m.rows[namespace]; ok {
c2 := c
return &c2, nil
}
return nil, nil
}
func (m *memStore) Upsert(_ context.Context, cfg Config) error {
m.mu.Lock()
defer m.mu.Unlock()
m.rows[cfg.Namespace] = cfg
return nil
}
func (m *memStore) Delete(_ context.Context, namespace string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.rows, namespace)
return nil
}
// ----------------------------------------------------------------------------
// Defaults.Sane
// ----------------------------------------------------------------------------
func TestDefaults_Sane(t *testing.T) {
cases := []struct {
name string
in Defaults
want Defaults
}{
{
"zero clamps to safe baseline",
Defaults{},
Defaults{RequestsPerMinute: 10_000, Burst: 5_000},
},
{
"populated values pass through",
Defaults{RequestsPerMinute: 500, Burst: 50, MaxRequestsPerMinute: 1000, MaxBurst: 100},
Defaults{RequestsPerMinute: 500, Burst: 50, MaxRequestsPerMinute: 1000, MaxBurst: 100},
},
{
"negative clamps to baseline",
Defaults{RequestsPerMinute: -1, Burst: -1},
Defaults{RequestsPerMinute: 10_000, Burst: 5_000},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := tc.in.Sane()
if got != tc.want {
t.Errorf("Sane() = %+v, want %+v", got, tc.want)
}
})
}
}
// ----------------------------------------------------------------------------
// Manager.Allow — base behaviour
// ----------------------------------------------------------------------------
func TestManager_Allow_emptyNamespaceAlwaysAllowed(t *testing.T) {
m := NewManager(newMemStore(), Defaults{RequestsPerMinute: 1, Burst: 1}, nil)
for i := 0; i < 10; i++ {
if !m.Allow(context.Background(), "") {
t.Fatal("empty namespace must always be allowed (per-IP limiter handles that layer)")
}
}
}
func TestManager_Allow_burstThenRefill(t *testing.T) {
// Burst of 3 → first 3 requests pass, 4th fails.
m := NewManager(newMemStore(), Defaults{RequestsPerMinute: 60, Burst: 3}, nil)
ns := "test-ns"
for i := 0; i < 3; i++ {
if !m.Allow(context.Background(), ns) {
t.Errorf("request %d should be allowed (within burst)", i+1)
}
}
if m.Allow(context.Background(), ns) {
t.Error("request 4 should be denied (burst exhausted)")
}
}
// ----------------------------------------------------------------------------
// Manager — per-namespace config override
// ----------------------------------------------------------------------------
func TestManager_Allow_perNamespaceOverride(t *testing.T) {
store := newMemStore()
// One namespace gets a generous override; another uses defaults.
store.rows["loud-tenant"] = Config{
Namespace: "loud-tenant",
RequestsPerMinute: 60_000,
Burst: 100,
}
m := NewManager(store, Defaults{RequestsPerMinute: 60, Burst: 1}, nil)
// Default-namespace can fire only 1 request before being throttled.
if !m.Allow(context.Background(), "quiet-tenant") {
t.Error("first quiet-tenant request should pass")
}
if m.Allow(context.Background(), "quiet-tenant") {
t.Error("second quiet-tenant request should be throttled (burst=1)")
}
// loud-tenant has the override, burst=100, so 50 in a row all pass.
for i := 0; i < 50; i++ {
if !m.Allow(context.Background(), "loud-tenant") {
t.Fatalf("loud-tenant request %d should pass under override (burst=100)", i+1)
}
}
}
// ----------------------------------------------------------------------------
// Manager — store error degrades to defaults (fail-open is the safer mode)
// ----------------------------------------------------------------------------
func TestManager_Allow_storeErrorFallsBackToDefaults(t *testing.T) {
store := newMemStore()
store.getErr = errSentinel("boom")
m := NewManager(store, Defaults{RequestsPerMinute: 60, Burst: 1}, nil)
if !m.Allow(context.Background(), "any-ns") {
t.Error("first request should pass under default burst even when store errs")
}
if m.Allow(context.Background(), "any-ns") {
t.Error("second request should fail under default burst (store errored, defaults applied)")
}
}
type errSentinel string
func (e errSentinel) Error() string { return string(e) }
// ----------------------------------------------------------------------------
// Manager.Invalidate — cache miss after invalidate picks up new config
// ----------------------------------------------------------------------------
func TestManager_Invalidate_rebuildsWithNewConfig(t *testing.T) {
store := newMemStore()
// Initial: tight limit (burst=1).
store.rows["tenant"] = Config{Namespace: "tenant", RequestsPerMinute: 60, Burst: 1}
m := NewManager(store, Defaults{RequestsPerMinute: 60, Burst: 1}, nil)
if !m.Allow(context.Background(), "tenant") {
t.Fatal("first request should pass")
}
if m.Allow(context.Background(), "tenant") {
t.Fatal("second request should be denied (burst=1)")
}
// Operator/tenant bumps the limit. Manager doesn't see it yet —
// previous limiter is cached.
store.rows["tenant"] = Config{Namespace: "tenant", RequestsPerMinute: 60, Burst: 100}
if m.Allow(context.Background(), "tenant") {
t.Error("without Invalidate, manager should still use the old cached limiter")
}
// Invalidate clears the cache → next request rebuilds with new burst.
m.Invalidate("tenant")
for i := 0; i < 50; i++ {
if !m.Allow(context.Background(), "tenant") {
t.Fatalf("post-invalidate request %d should pass under new config (burst=100)", i+1)
}
}
}
// ----------------------------------------------------------------------------
// Manager — concurrent access doesn't double-build limiters
// ----------------------------------------------------------------------------
func TestManager_concurrentBuilds_oneCanonicalLimiter(t *testing.T) {
store := newMemStore()
store.rows["tenant"] = Config{Namespace: "tenant", RequestsPerMinute: 60, Burst: 10}
m := NewManager(store, Defaults{RequestsPerMinute: 60, Burst: 10}, nil)
const goroutines = 50
var allowedCount int
var mu sync.Mutex
var wg sync.WaitGroup
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if m.Allow(context.Background(), "tenant") {
mu.Lock()
allowedCount++
mu.Unlock()
}
}()
}
wg.Wait()
// With burst=10 and 50 concurrent goroutines all hitting the same
// namespace, exactly 10 should be allowed (or thereabouts — token
// refill happens too fast for clock to matter at these intervals).
// Most importantly: NOT 500 (which would happen if each goroutine
// got its own freshly-built limiter due to a race).
if allowedCount > 15 || allowedCount < 5 {
t.Errorf("allowed = %d; expected ~10 (burst=10), got way off — suggests racy double-build", allowedCount)
}
}
// ----------------------------------------------------------------------------
// Manager.Defaults — exposes operator ceiling for handler validation
// ----------------------------------------------------------------------------
func TestManager_Defaults_exposesOperatorCeiling(t *testing.T) {
defs := Defaults{
RequestsPerMinute: 1000,
Burst: 100,
MaxRequestsPerMinute: 5000,
MaxBurst: 500,
}
m := NewManager(nil, defs, nil)
got := m.Defaults()
if got.MaxRequestsPerMinute != 5000 || got.MaxBurst != 500 {
t.Errorf("Defaults().Max* = (%d,%d), want (5000,500)",
got.MaxRequestsPerMinute, got.MaxBurst)
}
}