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") } }