Files
lukaszraczylo 65b2f121c4 feat(admin): add fleet statistics and admin read endpoints
Counters (always on when admin is enabled):
- last seen per user and group, written asynchronously after
  admission, throttled to one write per scope per minute per replica
- hourly provider and model attempts, failures and timeouts
- failovers per provider, rejections per hour, and unpriced (402)
  refusals
- cache hits, misses and cost saved
- request counts per target and caller

Opt-in counters under admin.stats (both default off):
- userModel: usage per user and model
- latency: latency histograms per provider and model

All counters ride the existing Redis batches. A test measures three
round trips per request with defaults and with every statistic on. A
cache hit takes two. A model request is no longer counted for failed
failover candidates.

New admin endpoints:
- usage/series and usage/totals
- performance, with percentiles from the histograms
- catalog, with the price billing actually uses and its source
- consumers, with key source, grants and model access
- config, with secrets redacted

The overview gains features and a pricing summary. Usage gains
lastSeen. usage/models gains offset and more detail fields. Reads are
chunked at 1600 keys per round trip and capped at 64000 keys per
request.

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
2026-09-23 16:40:45 +01:00

1149 lines
44 KiB
Go

package traefikllmgateway
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
)
func TestCreateConfig_ReturnsEmptyConfig(t *testing.T) {
cfg := CreateConfig()
if cfg == nil {
t.Fatal("CreateConfig returned nil")
}
if len(cfg.Providers) != 0 {
t.Fatalf("want zero providers, got %d", len(cfg.Providers))
}
}
func TestNew_NoProviders_ReturnsError(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := New(context.Background(), next, CreateConfig(), "llmgw")
if err == nil {
t.Fatal("want error for config with no providers, got nil")
}
if h != nil {
t.Fatal("want nil handler on error")
}
}
func TestNew_NilConfig_ReturnsError(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := New(context.Background(), next, nil, "llmgw")
if err == nil {
t.Fatal("want error for nil config, got nil")
}
if h != nil {
t.Fatal("want nil handler on error")
}
}
func TestGateway_PassthroughUnknown_ForwardsToNext(t *testing.T) {
called := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusTeapot)
})
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
cfg.PassthroughUnknown = true
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/anything", nil)
h.ServeHTTP(rec, req)
if !called {
t.Fatal("want next handler called when PassthroughUnknown is true")
}
if rec.Code != http.StatusTeapot {
t.Fatalf("want %d from next handler, got %d", http.StatusTeapot, rec.Code)
}
}
func TestWriteOAIError_WritesEnvelope(t *testing.T) {
rec := httptest.NewRecorder()
writeOAIError(rec, http.StatusBadRequest, "invalid_request_error", "bad request")
if rec.Code != http.StatusBadRequest {
t.Fatalf("want status %d, got %d", http.StatusBadRequest, rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Fatalf("want application/json, got %q", ct)
}
var body struct {
Error struct {
Message string `json:"message"`
Type string `json:"type"`
Code string `json:"code"`
} `json:"error"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Error.Message != "bad request" {
t.Fatalf("want message %q, got %q", "bad request", body.Error.Message)
}
if body.Error.Type != "invalid_request_error" {
t.Fatalf("want type %q, got %q", "invalid_request_error", body.Error.Type)
}
wantCode := strconv.Itoa(http.StatusBadRequest)
if body.Error.Code != wantCode {
t.Fatalf("want code %q (JSON string), got %q", wantCode, body.Error.Code)
}
}
func TestServeHTTP_PanicRecovery_Returns500Envelope(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
gw, ok := h.(*Gateway)
if !ok {
t.Fatal("handler is not *Gateway")
}
origStderr := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
defer func() { os.Stderr = origStderr }()
rec := httptest.NewRecorder()
sw := &statusTrackingWriter{ResponseWriter: rec}
func() {
defer recoverPanic(sw, gw)
panic("boom")
}()
_ = w.Close() // closing the pipe write end to unblock the read; error not actionable in a test
os.Stderr = origStderr
var buf bytes.Buffer
if _, err := io.Copy(&buf, r); err != nil {
t.Fatalf("io.Copy: %v", err)
}
if rec.Code != http.StatusInternalServerError {
t.Fatalf("want %d, got %d", http.StatusInternalServerError, rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Fatalf("want application/json, got %q", ct)
}
if !strings.Contains(buf.String(), "llmgw[llmgw] ERROR ") {
t.Fatalf("want stderr to contain error log prefix, got %q", buf.String())
}
}
func TestGateway_Logf_WritesInfoPrefix(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
h, err := New(context.Background(), next, cfg, "mygw")
if err != nil {
t.Fatalf("New: %v", err)
}
gw := h.(*Gateway)
origStderr := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
gw.logf("hello %s", "world")
_ = w.Close() // closing the pipe write end to unblock the read; error not actionable in a test
os.Stderr = origStderr
var buf bytes.Buffer
if _, err := io.Copy(&buf, r); err != nil {
t.Fatalf("io.Copy: %v", err)
}
want := "llmgw[mygw] INFO hello world"
if !strings.Contains(buf.String(), want) {
t.Fatalf("want stderr to contain %q, got %q", want, buf.String())
}
}
// TestServeHTTP_AuthFailed_LogsMethodPathNotKey proves a failed
// authentication attempt (I3) emits a log line naming the method and path,
// and proves the presented (wrong) API key never appears in that line —
// key material must never be logged, even on a failure.
func TestServeHTTP_AuthFailed_LogsMethodPathNotKey(t *testing.T) {
const presentedKey = "sk-wrong-should-never-be-logged"
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
cfg.Groups = map[string]*GroupConfig{"default": {}}
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice"}}}
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
origStderr := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{}`))
req.Header.Set("Authorization", "Bearer "+presentedKey)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
_ = w.Close() // closing the pipe write end to unblock the read; error not actionable in a test
os.Stderr = origStderr
var buf bytes.Buffer
if _, err := io.Copy(&buf, r); err != nil {
t.Fatalf("io.Copy: %v", err)
}
logged := buf.String()
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401, body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(logged, "auth failed") || !strings.Contains(logged, "POST") || !strings.Contains(logged, "/v1/chat/completions") {
t.Fatalf("want stderr to contain an auth-failed line with method and path, got %q", logged)
}
if strings.Contains(logged, presentedKey) {
t.Fatalf("stderr contains the presented API key %q — key material must never be logged: %q", presentedKey, logged)
}
}
// TestServeHTTP_AuthOK_LogsUserNameAndRoute proves a successful
// authentication attempt logs the resolved user's name and the route,
// without ever logging the API key that authenticated them.
func TestServeHTTP_AuthOK_LogsUserNameAndRoute(t *testing.T) {
const presentedKey = "sk-alice-should-never-be-logged"
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
cfg.Groups = map[string]*GroupConfig{"default": {}}
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: presentedKey}}}
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
origStderr := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
req.Header.Set("Authorization", "Bearer "+presentedKey)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
_ = w.Close() // closing the pipe write end to unblock the read; error not actionable in a test
os.Stderr = origStderr
var buf bytes.Buffer
if _, err := io.Copy(&buf, r); err != nil {
t.Fatalf("io.Copy: %v", err)
}
logged := buf.String()
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(logged, "auth ok") || !strings.Contains(logged, `"alice"`) || !strings.Contains(logged, "/v1/models") {
t.Fatalf("want stderr to contain an auth-ok line naming user alice and route /v1/models, got %q", logged)
}
if strings.Contains(logged, presentedKey) {
t.Fatalf("stderr contains the presented API key %q — key material must never be logged: %q", presentedKey, logged)
}
}
func TestGateway_Errorf_WritesErrorPrefix(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
h, err := New(context.Background(), next, cfg, "mygw")
if err != nil {
t.Fatalf("New: %v", err)
}
gw := h.(*Gateway)
origStderr := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
gw.errorf("failed: %s", "reason")
_ = w.Close() // closing the pipe write end to unblock the read; error not actionable in a test
os.Stderr = origStderr
var buf bytes.Buffer
if _, err := io.Copy(&buf, r); err != nil {
t.Fatalf("io.Copy: %v", err)
}
want := "llmgw[mygw] ERROR failed: reason"
if !strings.Contains(buf.String(), want) {
t.Fatalf("want stderr to contain %q, got %q", want, buf.String())
}
}
func TestStatusTrackingWriter_WriteHeaderSetsFlag(t *testing.T) {
rec := httptest.NewRecorder()
sw := &statusTrackingWriter{ResponseWriter: rec}
if sw.wroteHeader {
t.Fatal("want wroteHeader false before any write")
}
sw.WriteHeader(http.StatusAccepted)
if !sw.wroteHeader {
t.Fatal("want wroteHeader true after WriteHeader")
}
if rec.Code != http.StatusAccepted {
t.Fatalf("want delegated status %d, got %d", http.StatusAccepted, rec.Code)
}
}
func TestStatusTrackingWriter_WriteSetsFlag(t *testing.T) {
rec := httptest.NewRecorder()
sw := &statusTrackingWriter{ResponseWriter: rec}
if _, err := sw.Write([]byte("hi")); err != nil {
t.Fatalf("Write: %v", err)
}
if !sw.wroteHeader {
t.Fatal("want wroteHeader true after an implicit-header Write")
}
if rec.Body.String() != "hi" {
t.Fatalf("want delegated body %q, got %q", "hi", rec.Body.String())
}
}
func TestStatusTrackingWriter_FlushDelegates(t *testing.T) {
rec := httptest.NewRecorder()
sw := &statusTrackingWriter{ResponseWriter: rec}
sw.Flush()
if !rec.Flushed {
t.Fatal("want Flush to delegate to the underlying http.Flusher")
}
}
// TestServeHTTP_PanicAfterHeadersCommitted_DoesNotOverwriteResponse is the
// regression test for the recoverPanic header-guard: a handler that panics
// after it has already written a status code and body bytes (e.g.
// mid-stream) must not get a second, conflicting error envelope appended.
func TestServeHTTP_PanicAfterHeadersCommitted_DoesNotOverwriteResponse(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
if _, err := w.Write([]byte("partial")); err != nil {
t.Fatalf("next handler Write: %v", err)
}
panic("boom mid-stream")
})
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
cfg.PassthroughUnknown = true
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
origStderr := os.Stderr
pr, pw, _ := os.Pipe()
os.Stderr = pw
defer func() { os.Stderr = origStderr }()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/stream", nil)
h.ServeHTTP(rec, req)
_ = pw.Close() // closing the pipe write end to unblock the read; error not actionable in a test
os.Stderr = origStderr
var logBuf bytes.Buffer
if _, err := io.Copy(&logBuf, pr); err != nil {
t.Fatalf("io.Copy: %v", err)
}
if rec.Code != http.StatusOK {
t.Fatalf("want status left at %d (already committed by next), got %d", http.StatusOK, rec.Code)
}
if got := rec.Body.String(); got != "partial" {
t.Fatalf("want body left as %q (no error envelope appended), got %q", "partial", got)
}
if !strings.Contains(logBuf.String(), "llmgw[llmgw] ERROR ") {
t.Fatalf("want panic still logged even though no envelope was written, got %q", logBuf.String())
}
}
// TestNewGateway_UsersFile_InitialLoadFailure_ReturnsConstructorError is the
// fail-fast case: a malformed users file must fail plugin construction, not
// leave the plugin running with an empty file-sourced user set.
func TestNewGateway_UsersFile_InitialLoadFailure_ReturnsConstructorError(t *testing.T) {
dir := t.TempDir()
fp := filepath.Join(dir, "bad.json")
if err := os.WriteFile(fp, []byte("{not json"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
cfg.Users = &UsersConfig{File: fp}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
if err == nil {
t.Fatal("want constructor error for a malformed initial users file")
}
if h != nil {
t.Fatal("want nil handler on error")
}
}
// TestNewGateway_UsersFile_InitialLoadMissingFile_ReturnsConstructorError
// covers the other fail-fast path: a configured file that does not exist.
func TestNewGateway_UsersFile_InitialLoadMissingFile_ReturnsConstructorError(t *testing.T) {
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
cfg.Users = &UsersConfig{File: filepath.Join(t.TempDir(), "nope.json")}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
if err == nil {
t.Fatal("want constructor error for a missing initial users file")
}
if h != nil {
t.Fatal("want nil handler on error")
}
}
// TestNewGateway_UsersFile_ValidFile_LoadsUsersSynchronously verifies the
// synchronous initial load: a file-sourced user must be identifiable
// immediately after New returns, with no reload wait.
func TestNewGateway_UsersFile_ValidFile_LoadsUsersSynchronously(t *testing.T) {
dir := t.TempDir()
fp := filepath.Join(dir, "users.json")
writeUsersDoc(t, fp, []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice"}}, time.Time{})
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
cfg.Groups = map[string]*GroupConfig{"default": {}}
cfg.Users = &UsersConfig{File: fp}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
gw, ok := h.(*Gateway)
if !ok {
t.Fatal("handler is not *Gateway")
}
if _, _, ok := identifyWithKey(gw.auth, "sk-alice"); !ok {
t.Fatal("want file-sourced user identifiable immediately after construction")
}
}
// TestServeHTTP_NoUsersFile_MaybeReloadIsNoOp verifies that entry-time
// maybeReload is a cheap no-op — and, crucially, does not panic — when no
// users file is configured.
func TestServeHTTP_NoUsersFile_MaybeReloadIsNoOp(t *testing.T) {
called := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
})
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
cfg.PassthroughUnknown = true
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/x", nil)
h.ServeHTTP(rec, req)
if !called {
t.Fatal("want next handler called")
}
}
// TestServeHTTP_TriggersUsersFileReload is the wire-up regression: ServeHTTP
// must call g.auth.maybeReload() at entry, not just newGateway's initial
// load. It drives a real request through ServeHTTP (never calling
// maybeReload directly) and expects the file change to have been picked up.
func TestServeHTTP_TriggersUsersFileReload(t *testing.T) {
dir := t.TempDir()
fp := filepath.Join(dir, "users.json")
writeUsersDoc(t, fp, []*UserConfig{{Name: "f1", Group: "default", APIKey: "sk-f1"}}, time.Time{})
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
cfg.Groups = map[string]*GroupConfig{"default": {}}
cfg.Users = &UsersConfig{File: fp}
cfg.PassthroughUnknown = true
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
gw, ok := h.(*Gateway)
if !ok {
t.Fatal("handler is not *Gateway")
}
// Force the throttle window open and give the file an unambiguous
// future mtime, then drive one request through ServeHTTP.
clock := &fakeClock{now: gw.auth.nowFn().Add(reloadEvery + time.Second)}
gw.auth.nowFn = clock.Now
writeUsersDoc(t, fp, []*UserConfig{{Name: "f2", Group: "default", APIKey: "sk-f2"}}, time.Now().Add(time.Hour))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/x", nil)
h.ServeHTTP(rec, req)
if _, _, ok := identifyWithKey(gw.auth, "sk-f2"); !ok {
t.Fatal("want ServeHTTP's entry-time maybeReload call to have picked up the file change")
}
}
// TestNewGateway_RejectsInvalidGroupLimits is carried-item (a): a negative
// limit value in a group's LimitsConfig must fail plugin construction, not
// silently behave as unlimited.
func TestNewGateway_RejectsInvalidGroupLimits(t *testing.T) {
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
cfg.Groups = map[string]*GroupConfig{"default": {Limits: &LimitsConfig{RequestsPerDay: -1}}}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
if _, err := New(context.Background(), next, cfg, "llmgw"); err == nil {
t.Fatal("want constructor error for a negative group limit")
}
}
// TestNewGateway_RejectsInvalidUserLimits mirrors the group case for an
// inline user's own limits override.
func TestNewGateway_RejectsInvalidUserLimits(t *testing.T) {
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
cfg.Groups = map[string]*GroupConfig{"default": {}}
cfg.Users = &UsersConfig{Inline: []*UserConfig{
{Name: "a", Group: "default", APIKey: "sk-a", Limits: &LimitsConfig{CostPerMonthUSD: -1}},
}}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
if _, err := New(context.Background(), next, cfg, "llmgw"); err == nil {
t.Fatal("want constructor error for a negative user limit")
}
}
// TestNewGateway_RejectsInvalidFileUserLimits covers the file-sourced user
// path (attachUsersFile -> replaceFileUsers -> buildEntry), the other
// caller of LimitsConfig.validate besides newAuthStore's inline loop.
func TestNewGateway_RejectsInvalidFileUserLimits(t *testing.T) {
dir := t.TempDir()
fp := filepath.Join(dir, "users.json")
writeUsersDoc(t, fp, []*UserConfig{
{Name: "f", Group: "default", APIKey: "sk-f", Limits: &LimitsConfig{TokensPerDay: -1}},
}, time.Time{})
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
cfg.Groups = map[string]*GroupConfig{"default": {}}
cfg.Users = &UsersConfig{File: fp}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
if _, err := New(context.Background(), next, cfg, "llmgw"); err == nil {
t.Fatal("want constructor error for a negative file-sourced user limit")
}
}
// TestNewGateway_NoRedis_UsesMemoryLimiterFailOpenDefaultTrue is
// carried-item (d)'s "else" branch: no Redis configured wires
// newLimiter(nil, true) — a nil store (memoryStore fallback only) and
// failOpen defaulted true.
func TestNewGateway_NoRedis_UsesMemoryLimiterFailOpenDefaultTrue(t *testing.T) {
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
gw := h.(*Gateway)
if gw.limiter == nil {
t.Fatal("want a non-nil limiter")
}
if gw.limiter.store != nil {
t.Errorf("gw.limiter.store = %#v, want nil (no Redis configured)", gw.limiter.store)
}
if !gw.limiter.failOpen {
t.Error("gw.limiter.failOpen = false, want true (default)")
}
}
// TestNewGateway_RedisConfigured_WiresRedisStoreAndFailOpen is
// carried-item (d)'s main branch: a configured Redis block wires a
// redisStore (backed by a respClient) into the limiter, and an explicit
// FailOpen=false is honored rather than overridden by the default.
func TestNewGateway_RedisConfigured_WiresRedisStoreAndFailOpen(t *testing.T) {
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
failOpen := false
cfg.Redis = &RedisConfig{Address: "127.0.0.1:0", FailOpen: &failOpen}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
gw := h.(*Gateway)
if gw.limiter == nil {
t.Fatal("want a non-nil limiter")
}
if _, ok := gw.limiter.store.(*redisStore); !ok {
t.Errorf("gw.limiter.store = %#v (%T), want *redisStore", gw.limiter.store, gw.limiter.store)
}
if gw.limiter.failOpen {
t.Error("gw.limiter.failOpen = true, want false (explicit config)")
}
}
// TestGateway_Close_NoRedis_NilSafeNoOp is the review fix (Should-Fix 4,
// 2026-08-2x): a Gateway built with no Redis configured has a nil
// redisClient, and Close must be a nil-safe no-op, not a panic.
func TestGateway_Close_NoRedis_NilSafeNoOp(t *testing.T) {
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
gw := h.(*Gateway)
if gw.redisClient != nil {
t.Fatal("gw.redisClient != nil, want nil (no Redis configured)")
}
if err := gw.Close(); err != nil {
t.Errorf("Close: %v, want nil", err)
}
}
// TestGateway_Close_WithRedis_DrainsPooledConnections is the review fix
// (Should-Fix 4, 2026-08-2x) end-to-end proof: a Gateway built with Redis
// configured wires redisClient, and Close drains its connection pool
// (every slot, dialled or not — construction never eagerly dials, so
// this exercises Close's drain path rather than a real socket close, but
// proves the wiring: gw.Close() actually reaches gw.redisClient.Close(),
// not a stub that does nothing).
func TestGateway_Close_WithRedis_DrainsPooledConnections(t *testing.T) {
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
cfg.Redis = &RedisConfig{Address: "127.0.0.1:0", PoolSize: 3}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
gw := h.(*Gateway)
if gw.redisClient == nil {
t.Fatal("gw.redisClient = nil, want a non-nil client (Redis configured)")
}
if got := len(gw.redisClient.free); got != 3 {
t.Fatalf("len(redisClient.free) before Close = %d, want 3", got)
}
if err := gw.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if got := len(gw.redisClient.free); got != 0 {
t.Errorf("len(redisClient.free) after Close = %d, want 0 (drained)", got)
}
}
// TestNewGateway_RedisFailOpenNilDefaultsTrue asserts a configured Redis
// block with FailOpen left nil defaults to true, matching the no-Redis
// default.
func TestNewGateway_RedisFailOpenNilDefaultsTrue(t *testing.T) {
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
cfg.Redis = &RedisConfig{Address: "127.0.0.1:0"}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
gw := h.(*Gateway)
if !gw.limiter.failOpen {
t.Error("gw.limiter.failOpen = false, want true (FailOpen left nil)")
}
}
// TestNewGateway_RedisConfigured_EmptyAddress_ReturnsConstructorError
// asserts a Redis block with no address fails construction immediately,
// rather than deferring the error to the first lazy-connect attempt on
// the request path.
func TestNewGateway_RedisConfigured_EmptyAddress_ReturnsConstructorError(t *testing.T) {
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
cfg.Redis = &RedisConfig{}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
if _, err := New(context.Background(), next, cfg, "llmgw"); err == nil {
t.Fatal("want constructor error for a Redis block with an empty address")
}
}
// TestNewGateway_RedisConfigured_NegativeDB_ReturnsConstructorError is
// review item 5: a negative Redis.DB must fail construction immediately,
// same as an empty address, rather than reaching SELECT -1 on the wire.
func TestNewGateway_RedisConfigured_NegativeDB_ReturnsConstructorError(t *testing.T) {
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
cfg.Redis = &RedisConfig{Address: "127.0.0.1:0", DB: -1}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
if _, err := New(context.Background(), next, cfg, "llmgw"); err == nil {
t.Fatal("want constructor error for a negative Redis db")
}
}
// TestBuildRedisClient_NegativePoolSize_ReturnsConstructorError mirrors
// the negative-DB case above: RedisConfig.PoolSize must fail construction
// immediately, same as an empty address or a negative db, rather than
// reaching newRESPClientPool with a value it would otherwise silently
// clamp up to 1.
func TestBuildRedisClient_NegativePoolSize_ReturnsConstructorError(t *testing.T) {
_, err := buildRedisClient(&RedisConfig{Address: "127.0.0.1:0", PoolSize: -1})
if err == nil {
t.Fatal("want constructor error for a negative Redis poolSize")
}
}
// TestBuildRedisClient_PoolSizeAboveCeiling_ReturnsConstructorError is the
// review fix (Should-Fix 1, 2026-08-2x): PoolSize had no upper bound —
// PoolSize: 1<<20 was accepted and eagerly allocated over a million
// *respConn structs at construction. A value above respPoolConfigMax must
// fail construction immediately, mirroring the negative-poolSize check
// above.
func TestBuildRedisClient_PoolSizeAboveCeiling_ReturnsConstructorError(t *testing.T) {
_, err := buildRedisClient(&RedisConfig{Address: "127.0.0.1:0", PoolSize: respPoolConfigMax + 1})
if err == nil {
t.Fatal("want constructor error for a Redis poolSize above respPoolConfigMax")
}
}
// TestBuildRedisClient_PoolSizeAtCeiling_Allowed asserts the ceiling
// itself is inclusive — respPoolConfigMax is a valid, accepted value, not
// an off-by-one rejection.
func TestBuildRedisClient_PoolSizeAtCeiling_Allowed(t *testing.T) {
client, err := buildRedisClient(&RedisConfig{Address: "127.0.0.1:0", PoolSize: respPoolConfigMax})
if err != nil {
t.Fatalf("buildRedisClient: %v, want no error at exactly respPoolConfigMax", err)
}
if got := cap(client.free); got != respPoolConfigMax {
t.Errorf("cap(client.free) = %d, want %d", got, respPoolConfigMax)
}
}
// TestBuildRedisClient_PoolSize_ZeroUsesSelfTunedDefault_ExplicitOverrides
// is perf finding 1's config-wiring proof: RedisConfig.PoolSize left at 0
// gets defaultRespPoolSize's own self-tuned pool, but any explicit
// positive value always overrides it — the house engineering rule
// (self-tuning over operator knobs, explicit override always wins).
func TestBuildRedisClient_PoolSize_ZeroUsesSelfTunedDefault_ExplicitOverrides(t *testing.T) {
cases := []struct {
name string
poolSize int
wantPoolSize int
}{
{name: "zero uses the self-tuned default", poolSize: 0, wantPoolSize: defaultRespPoolSize()},
{name: "explicit override wins", poolSize: 3, wantPoolSize: 3},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
client, err := buildRedisClient(&RedisConfig{Address: "127.0.0.1:0", PoolSize: c.poolSize})
if err != nil {
t.Fatalf("buildRedisClient: %v", err)
}
if got := cap(client.free); got != c.wantPoolSize {
t.Errorf("cap(client.free) = %d, want %d", got, c.wantPoolSize)
}
})
}
}
// TestNewGateway_RedisPassword_ResolvedViaSecret is an end-to-end proof
// that newGateway resolves Config.Redis.Password through resolveSecret
// (env:/file:/literal) before handing it to respClient: an "env:" password
// must arrive at the fake server as its resolved value, not the literal
// "env:..." string.
func TestNewGateway_RedisPassword_ResolvedViaSecret(t *testing.T) {
t.Setenv("LLMGW_TEST_REDIS_PASSWORD", "resolved-pw")
ln := newFakeListener(t)
runFakeRESPServer(t, ln, []respStep{
{wantArgs: []string{"AUTH", "resolved-pw"}, reply: []byte("+OK\r\n")},
{wantArgs: []string{"SELECT", "0"}, reply: []byte("+OK\r\n")},
{wantArgs: []string{"GET", "k"}, reply: []byte("$-1\r\n")},
})
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
cfg.Redis = &RedisConfig{Address: ln.Addr().String(), Password: "env:LLMGW_TEST_REDIS_PASSWORD"} // #nosec G101 -- not a credential, a resolveSecret "env:" reference to an env var name
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
gw := h.(*Gateway)
store, ok := gw.limiter.store.(*redisStore)
if !ok {
t.Fatalf("gw.limiter.store = %#v (%T), want *redisStore", gw.limiter.store, gw.limiter.store)
}
if _, err := getOne(store, "k"); err != nil {
t.Fatalf("store.getMulti: %v (want the resolved password to authenticate against the fake server)", err)
}
}
// TestNewGateway_RedisPassword_UnresolvableSecret_ReturnsConstructorError
// asserts an "env:" password referencing an unset variable fails
// construction with resolveSecret's error, rather than deferring to a
// confusing AUTH failure at request time.
func TestNewGateway_RedisPassword_UnresolvableSecret_ReturnsConstructorError(t *testing.T) {
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
cfg.Redis = &RedisConfig{Address: "127.0.0.1:0", Password: "env:LLMGW_TEST_REDIS_PASSWORD_UNSET"} // #nosec G101 -- not a credential, a resolveSecret "env:" reference to an env var name
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
if _, err := New(context.Background(), next, cfg, "llmgw"); err == nil {
t.Fatal("want constructor error for an unresolvable Redis password secret")
}
}
// TestNewGateway_InvalidPricingOverride_ReturnsConstructorError is finding
// F4, 2026-09 review: New must reject a negative Config.Pricing entry at
// construction (validatePricing, pricing.go) rather than letting it reach
// costMicrosKnown and silently disable that model's cost budget forever.
func TestNewGateway_InvalidPricingOverride_ReturnsConstructorError(t *testing.T) {
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
cfg.Pricing = map[string]*ModelPricing{"gpt-5": {InputPerM: -1, OutputPerM: 1}}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
if _, err := New(context.Background(), next, cfg, "llmgw"); err == nil {
t.Fatal("want a constructor error for a negative pricing.inputPerM")
}
}
// TestNewGateway_MetricsPath_Validation is review-auth finding F9,
// 2026-09 review, corrected by the verify-core round-4 fix: metrics.path
// must still start with "/" (a hard construction error — a value with no
// leading "/" can never match ServeHTTP's route comparison, silently
// making the endpoint unreachable), but a path sitting at or under a
// reserved route prefix (/admin, /v1, /mcp, /a2a) is no longer refused.
// ServeHTTP matches the metrics route by exact path before it ever
// dispatches into those namespaces, so a config like "/v1/metrics"
// already routed correctly before finding F9's fix existed at all —
// refusing to build the middleware for it broke a previously working
// config on upgrade. It now only logs a WARN line naming the shadowed
// prefix.
func TestNewGateway_MetricsPath_Validation(t *testing.T) {
cases := []struct {
path string
wantErr bool
wantWarn bool
}{
{"/internal/metrics", false, false},
{"", false, false}, // empty resolves to the default "/metrics"
{"metrics", true, false}, // no leading "/": still a hard error
{"/admin", false, true}, // exact collision: now just a warning
{"/admin/api/usage", false, true}, // under the admin namespace
{"/v1/metrics", false, true}, // under the OpenAI-compatible namespace — previously working; must still build
{"/mcp/prometheus", false, true}, // under the MCP target-proxy namespace
{"/a2a/prometheus", false, true}, // under the A2A target-proxy namespace
{"/administrivia", false, false}, // string prefix only, not a path-segment collision
{"/v1/models", false, true}, // GET /v1/models is matched before metrics: unreachable
}
for _, c := range cases {
t.Run(c.path, func(t *testing.T) {
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
cfg.Metrics = &MetricsConfig{Enabled: true, Path: c.path}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
origStderr := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
_, err := New(context.Background(), next, cfg, "llmgw")
_ = w.Close() // closing the pipe write end to unblock the read; error not actionable in a test
os.Stderr = origStderr
var buf bytes.Buffer
if _, copyErr := io.Copy(&buf, r); copyErr != nil {
t.Fatalf("io.Copy: %v", copyErr)
}
if (err != nil) != c.wantErr {
t.Errorf("New with metrics.path = %q: err = %v, wantErr %v", c.path, err, c.wantErr)
}
if gotWarn := strings.Contains(buf.String(), "llmgw[llmgw] WARN config: metrics.path"); gotWarn != c.wantWarn {
t.Errorf("New with metrics.path = %q: shadow warning logged = %v, want %v (stderr: %q)", c.path, gotWarn, c.wantWarn, buf.String())
}
wantUnreachable := c.path == "/v1/models"
if gotUnreachable := strings.Contains(buf.String(), "unreachable"); gotUnreachable != wantUnreachable {
t.Errorf("New with metrics.path = %q: unreachable warning = %v, want %v (stderr: %q)", c.path, gotUnreachable, wantUnreachable, buf.String())
}
})
}
}
// TestGateway_PricingWarn_LogsUnderOwnInstance: an unpriced model's
// warning routed through g.pricingWarn reaches that gateway's own log
// prefix (finding F11 / review-auth F8: no package-global warn func).
func TestGateway_PricingWarn_LogsUnderOwnInstance(t *testing.T) {
warnedModelsMu.Lock()
prevWarned, prevCapNotified := warnedModels, warnCapNotified
warnedModels = map[string]bool{}
warnCapNotified = false
warnedModelsMu.Unlock()
t.Cleanup(func() {
warnedModelsMu.Lock()
warnedModels, warnCapNotified = prevWarned, prevCapNotified
warnedModelsMu.Unlock()
})
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := New(context.Background(), next, cfg, "mygw")
if err != nil {
t.Fatalf("New: %v", err)
}
gw := h.(*Gateway)
origStderr := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
costMicrosKnownFor("totally-unpriced-model-for-newgateway-test", usage{prompt: 1}, nil, gw.pricingWarn)
_ = w.Close() // closing the pipe write end to unblock the read; error not actionable in a test
os.Stderr = origStderr
var buf bytes.Buffer
if _, err := io.Copy(&buf, r); err != nil {
t.Fatalf("io.Copy: %v", err)
}
if !strings.Contains(buf.String(), "llmgw[mygw]") {
t.Fatalf("want the pricing warning routed through the gateway's own logf, got %q", buf.String())
}
}
// TestNewCollectsConfigWarnings is F10 (v0.3 dashboard task): every
// construction-time warnf call — here, metrics.path="/v1/models"
// (shadowed-route warning) AND cache.ttl="0s" (zero-TTL warning),
// triggered together in one construction — lands in g.configWarnings,
// readable via GET /admin/api/overview's Warnings field. Both are real
// existing warnf call sites (TestNewGateway_MetricsPath_Validation/
// TestBuildResponseCache_NonPositiveTTL_WarnsAndAccepts prove they fire
// at all); this test proves F10's NEW behavior — that warnf's own line
// is ALSO collected, not just written to stderr.
func TestNewCollectsConfigWarnings(t *testing.T) {
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
cfg.Metrics = &MetricsConfig{Enabled: true, Path: "/v1/models"}
cfg.Cache = CacheConfig{Enabled: true, TTL: "0s"} // no Redis configured — buildResponseCache still warns before its own nil-client return
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
gw := h.(*Gateway)
warnings, dropped := gw.configWarningsSnapshot()
if dropped != 0 {
t.Errorf("dropped = %d, want 0", dropped)
}
var sawMetricsPath, sawCacheTTL bool
for _, w := range warnings {
if strings.Contains(w, "metrics.path") {
sawMetricsPath = true
}
if strings.Contains(w, "cache: ttl") {
sawCacheTTL = true
}
}
if !sawMetricsPath {
t.Errorf("warnings = %v, want one mentioning metrics.path", warnings)
}
if !sawCacheTTL {
t.Errorf("warnings = %v, want one mentioning cache: ttl", warnings)
}
}
// TestNoteConfigWarning_CapAndDropped proves configWarningsCap bounds
// g.configWarnings and configWarningsDropped counts what did not fit —
// F10's own overflow behavior, exercised directly against noteConfigWarning
// (via warnf) rather than contriving 50+ real construction warnings.
func TestNoteConfigWarning_CapAndDropped(t *testing.T) {
g := &Gateway{name: "llmgw", collectingWarnings: true}
const over = 5
for i := 0; i < configWarningsCap+over; i++ {
g.warnf("warning %d", i)
}
warnings, dropped := g.configWarningsSnapshot()
if len(warnings) != configWarningsCap {
t.Errorf("len(warnings) = %d, want %d", len(warnings), configWarningsCap)
}
if dropped != over {
t.Errorf("dropped = %d, want %d", dropped, over)
}
if warnings[0] != "warning 0" {
t.Errorf("warnings[0] = %q, want %q (the first configWarningsCap warnings are kept, not the last)", warnings[0], "warning 0")
}
}
// TestNoteConfigWarning_RuntimeWarnfNotCollected proves a warnf call
// AFTER collectingWarnings is false (construction finished — newGateway's
// own final step) is never appended to g.configWarnings: only
// construction-time warnings belong in GET /admin/api/overview's
// Warnings field.
func TestNoteConfigWarning_RuntimeWarnfNotCollected(t *testing.T) {
g := &Gateway{name: "llmgw", collectingWarnings: true}
g.warnf("during construction")
g.collectingWarnings = false
g.warnf("after construction, must not be collected")
warnings, _ := g.configWarningsSnapshot()
if len(warnings) != 1 || warnings[0] != "during construction" {
t.Errorf("warnings = %v, want exactly [%q]", warnings, "during construction")
}
}
// TestShouldSendTelemetry pins the guard that keeps unstamped builds from
// phoning home. Every developer checkout and every CI run carries
// devPluginVersion, so the false cases are the state this code is in
// nearly all of the time: if the guard inverts, `go test` itself starts
// emitting pings.
func TestShouldSendTelemetry(t *testing.T) {
tests := []struct {
name string
version string
want bool
}{
{"dev sentinel stays silent", devPluginVersion, false},
{"empty version stays silent", "", false},
{"stamped release sends", "0.2.38", true},
{"stamped prerelease sends", "1.0.0-rc1", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shouldSendTelemetry(tt.version); got != tt.want {
t.Errorf("shouldSendTelemetry(%q) = %v, want %v", tt.version, got, tt.want)
}
})
}
}
// TestPluginVersion_UnstampedInSourceTree guards the committed state: the
// repository must never carry a stamped version, or a developer's own
// build would phone home. workflow-prepare.sh stamps a COPY at release
// time; if a stamped value is ever committed by mistake, this fails.
func TestPluginVersion_UnstampedInSourceTree(t *testing.T) {
if pluginVersion != devPluginVersion {
t.Errorf("committed pluginVersion = %q, want the %q sentinel — a stamped version must never be committed", pluginVersion, devPluginVersion)
}
}
// TestNew_DoesNotPanicOnRepeatedConstruction covers the telemetry
// sync.Once gate under the real call pattern: Traefik constructs this
// middleware once PER ROUTE, so New runs many times in one process.
// Construction must stay safe and cheap on every later call.
func TestNew_DoesNotPanicOnRepeatedConstruction(t *testing.T) {
t.Setenv("DO_NOT_TRACK", "1")
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
for i := 0; i < 5; i++ {
h, err := New(context.Background(), http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), cfg, "llmgw")
if err != nil {
t.Fatalf("New #%d: %v", i, err)
}
if h == nil {
t.Fatalf("New #%d returned a nil handler", i)
}
}
}
// TestSendStartupTelemetry_OnceAcrossConcurrentCalls pins the sync.Once
// gate itself: Traefik can construct several routes concurrently at
// startup, and oss-telemetry does not deduplicate client-side, so a
// missing gate would emit one ping per route.
func TestSendStartupTelemetry_OnceAcrossConcurrentCalls(t *testing.T) {
t.Setenv("DO_NOT_TRACK", "1")
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
sendStartupTelemetry()
}()
}
wg.Wait() // -race proves the Once is the only synchronisation needed
}
// --- admin-redesign WP-A: AdminConfig.Stats + newConfiguredLimiter wiring ---
// TestNewConfiguredLimiter_StatsFlags_DeriveFromAdminConfig proves
// newConfiguredLimiter derives statsAdmin/statsUserModel/statsLatency
// purely from Config.Admin — statsUserModel/statsLatency are ANDed with
// statsAdmin at construction (limiter.statsUserModel's own doc comment,
// limits.go), so a call site need only check the one field, never both.
func TestNewConfiguredLimiter_StatsFlags_DeriveFromAdminConfig(t *testing.T) {
cases := []struct {
admin *AdminConfig
name string
wantStatsAdmin bool
wantStatsUserModel bool
wantStatsLatency bool
}{
{name: "nil Admin: every flag off", admin: nil},
{name: "Admin disabled: every flag off", admin: &AdminConfig{Enabled: false}},
{name: "Admin enabled, nil Stats: statsAdmin on, sub-flags off",
admin: &AdminConfig{Enabled: true}, wantStatsAdmin: true},
{name: "Admin enabled, Stats zero value: statsAdmin on, sub-flags off",
admin: &AdminConfig{Enabled: true, Stats: &AdminStatsConfig{}}, wantStatsAdmin: true},
{name: "Admin enabled, both sub-flags on",
admin: &AdminConfig{Enabled: true, Stats: &AdminStatsConfig{UserModel: true, Latency: true}},
wantStatsAdmin: true, wantStatsUserModel: true, wantStatsLatency: true},
{name: "Admin disabled but Stats set: every flag off (statsAdmin gates the sub-flags too)",
admin: &AdminConfig{Enabled: false, Stats: &AdminStatsConfig{UserModel: true, Latency: true}}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
cfg := &Config{Admin: c.admin}
l := newConfiguredLimiter(cfg, nil) // nil client: fallback-only path, still must derive the flags
if l.statsAdmin != c.wantStatsAdmin {
t.Errorf("statsAdmin = %v, want %v", l.statsAdmin, c.wantStatsAdmin)
}
if l.statsUserModel != c.wantStatsUserModel {
t.Errorf("statsUserModel = %v, want %v", l.statsUserModel, c.wantStatsUserModel)
}
if l.statsLatency != c.wantStatsLatency {
t.Errorf("statsLatency = %v, want %v", l.statsLatency, c.wantStatsLatency)
}
if l.lastSeen == nil {
t.Error("lastSeen is nil; newLimiter must always construct one")
}
})
}
}
// TestNewLimiter_DirectConstruction_EveryStatsFlagDefaultsFalse proves a
// limiter built directly via newLimiter (as most of this package's own
// tests do, bypassing newConfiguredLimiter/Config entirely) defaults every
// admin-stats flag to false — additive and default-preserving: existing
// tests must observe byte-identical behavior to before this task unless
// they opt in explicitly.
func TestNewLimiter_DirectConstruction_EveryStatsFlagDefaultsFalse(t *testing.T) {
l := newLimiter(nil, true)
if l.statsAdmin || l.statsUserModel || l.statsLatency {
t.Errorf("statsAdmin=%v statsUserModel=%v statsLatency=%v, want all false", l.statsAdmin, l.statsUserModel, l.statsLatency)
}
if l.lastSeen == nil {
t.Error("lastSeen is nil; newLimiter must always construct one, regardless of stats config")
}
}