Files
silo-server/internal/api/handlers/rate_limits_test.go
T
QuickandClaude Fable 5 cb6272ef25 fix(ratelimit): keep rate limit settings usable when limiter is disabled
The /admin/rate-limits/config routes were only mounted when the limiter
was running, and the limiter is only constructed at boot when
ratelimit.enabled is true. Disabling rate limiting from the UI and
restarting therefore 404'd the settings page permanently, with no way
to re-enable it without editing the database.

Mount the routes whenever the settings store exists and make the
handler tolerate a nil limiter: saves always persist, hot-reload is
skipped when nothing is running, and the PUT response reports
restart_required (enabling with no limiter built, or switching backend
on a running one). GET now exposes active/active_backend so the UI can
tell saved config apart from what the process is enforcing.

The settings page shows a persistent restart banner driven by that
server state, with the same restart flow as other settings pages via a
RestartServerButton extracted from SaveBar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 14:23:28 -04:00

141 lines
4.4 KiB
Go

package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/ratelimit"
)
type fakeRateLimitStore struct {
values map[string]string
}
func newFakeRateLimitStore() *fakeRateLimitStore {
return &fakeRateLimitStore{values: make(map[string]string)}
}
func (s *fakeRateLimitStore) Get(_ context.Context, key string) (string, error) {
return s.values[key], nil
}
func (s *fakeRateLimitStore) Set(_ context.Context, key, value string) error {
s.values[key] = value
return nil
}
func (s *fakeRateLimitStore) GetAll(_ context.Context) (map[string]string, error) {
out := make(map[string]string, len(s.values))
for k, v := range s.values {
out[k] = v
}
return out, nil
}
func newRunningRateLimitMiddleware(t *testing.T, store ratelimit.SettingsStore) *ratelimit.Middleware {
t.Helper()
perKey := ratelimit.NewMemoryLimiter()
global := ratelimit.NewMemoryLimiter()
t.Cleanup(func() {
perKey.Close()
global.Close()
})
mw := ratelimit.NewMiddleware(perKey, global, store, true)
if err := mw.Init(context.Background()); err != nil {
t.Fatalf("init middleware: %v", err)
}
return mw
}
func putRateLimitConfig(t *testing.T, h *RateLimitHandler, body string) map[string]any {
t.Helper()
req := httptest.NewRequest(http.MethodPut, "/admin/rate-limits/config", strings.NewReader(body))
rec := httptest.NewRecorder()
h.HandleUpdateConfig(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PUT status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode PUT response: %v", err)
}
return resp
}
func getRateLimitConfig(t *testing.T, h *RateLimitHandler) rateLimitConfigResponse {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/admin/rate-limits/config", nil)
rec := httptest.NewRecorder()
h.HandleGetConfig(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp rateLimitConfigResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode GET response: %v", err)
}
return resp
}
func TestRateLimitHandlerWithoutRunningLimiter(t *testing.T) {
// The limiter is nil when rate limiting was disabled at boot. The config
// endpoints must still work so an admin can re-enable it from the UI.
store := newFakeRateLimitStore()
store.values["ratelimit.enabled"] = "false"
h := NewRateLimitHandler(store, nil, nil)
got := getRateLimitConfig(t, h)
if got.Enabled {
t.Error("GET enabled = true, want false")
}
if got.Active {
t.Error("GET active = true, want false when no limiter is running")
}
resp := putRateLimitConfig(t, h, `{"enabled":true,"backend":"redis"}`)
if resp["restart_required"] != true {
t.Errorf("enabling without a running limiter: restart_required = %v, want true", resp["restart_required"])
}
if store.values["ratelimit.enabled"] != "true" {
t.Errorf("ratelimit.enabled = %q, want \"true\"", store.values["ratelimit.enabled"])
}
if store.values["ratelimit.backend"] != "redis" {
t.Errorf("ratelimit.backend = %q, want \"redis\"", store.values["ratelimit.backend"])
}
resp = putRateLimitConfig(t, h, `{"enabled":false,"backend":"redis"}`)
if resp["restart_required"] != false {
t.Errorf("saving disabled config without a running limiter: restart_required = %v, want false", resp["restart_required"])
}
}
func TestRateLimitHandlerWithRunningLimiter(t *testing.T) {
store := newFakeRateLimitStore()
mw := newRunningRateLimitMiddleware(t, store)
h := NewRateLimitHandler(store, mw, nil)
got := getRateLimitConfig(t, h)
if !got.Active {
t.Error("GET active = false, want true with a running limiter")
}
if got.ActiveBackend != "memory" {
t.Errorf("GET active_backend = %q, want \"memory\"", got.ActiveBackend)
}
// Toggling enabled hot-reloads; no restart needed when the backend matches.
resp := putRateLimitConfig(t, h, `{"enabled":false,"backend":"memory"}`)
if resp["restart_required"] != false {
t.Errorf("toggle on running limiter: restart_required = %v, want false", resp["restart_required"])
}
// Switching backend only takes effect at boot.
resp = putRateLimitConfig(t, h, `{"enabled":true,"backend":"redis"}`)
if resp["restart_required"] != true {
t.Errorf("backend change: restart_required = %v, want true", resp["restart_required"])
}
}