Files
traefik-llmgateway/auth_test.go
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

1302 lines
50 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package traefikllmgateway
import (
"fmt"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// TestIdentify is the brief's Step-1 table: bearer auth resolves the right
// user and group, and the group's model glob matches both the bare id and
// the provider-prefixed id.
func TestIdentify(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"eng": {Models: []string{"gpt-5*"}}},
Users: &UsersConfig{Inline: []*UserConfig{{Name: "a", Group: "eng", APIKey: "sk-secret"}}},
}
a, err := newAuthStore(cfg)
if err != nil {
t.Fatal(err)
}
r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
r.Header.Set("Authorization", "Bearer sk-secret")
u, grp, ok := a.identify(r)
if !ok || u.name != "a" || grp.name != "eng" {
t.Fatalf("identify failed: %v %v %v", u, grp, ok)
}
// allowsModel was removed (review-auth finding F6, 2026-09 audit —
// dead in non-test code, and a standalone check would reintroduce the
// cross-grant leak allowsProviderModel's per-grant coupling exists to
// prevent for a multi-grant principal); these assertions moved to
// allowsProviderModel, the real enforcement path every production
// caller already uses. "eng" has an empty Providers glob (matches
// every provider), so "openai" here exercises that path without
// constraining it.
if !grp.allowsProviderModel("openai", "gpt-5-mini") {
t.Error("glob should match")
}
// The match is an exact glob against the given string only — it does
// not strip a "provider/" prefix itself (fix(registry) ruling 3): a
// pattern like "gpt-5*" reaching a provider-prefixed request like
// "openai/gpt-5-mini" is the caller's job (modelRegistry.
// resolveAgainst / listFor build the bare-suffix candidate themselves),
// covered at the registry level in registry_test.go, not here.
if grp.allowsProviderModel("openai", "openai/gpt-5-mini") {
t.Error("must not match a provider-prefixed id on its own; prefix-stripping lives in the caller")
}
if grp.allowsProviderModel("openai", "claude-4") {
t.Error("should not match")
}
}
func testAuthCfg() *Config {
return &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"eng": {}},
Users: &UsersConfig{Inline: []*UserConfig{{Name: "a", Group: "eng", APIKey: "sk-secret"}}},
}
}
func TestIdentify_XAPIKeyHeader_OK(t *testing.T) {
a, err := newAuthStore(testAuthCfg())
if err != nil {
t.Fatal(err)
}
r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
r.Header.Set("x-api-key", "sk-secret")
u, grp, ok := a.identify(r)
if !ok || u.name != "a" || grp.name != "eng" {
t.Fatalf("identify via x-api-key failed: %v %v %v", u, grp, ok)
}
}
func TestIdentify_BearerCaseInsensitivePrefix(t *testing.T) {
a, err := newAuthStore(testAuthCfg())
if err != nil {
t.Fatal(err)
}
for _, prefix := range []string{"Bearer", "bearer", "BEARER", "BeArEr"} {
r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
r.Header.Set("Authorization", prefix+" sk-secret")
if _, _, ok := a.identify(r); !ok {
t.Errorf("prefix %q: want identify to succeed", prefix)
}
}
}
func TestIdentify_BearerWinsOverXAPIKey(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"eng": {}, "ops": {}},
Users: &UsersConfig{Inline: []*UserConfig{
{Name: "bearer-user", Group: "eng", APIKey: "sk-bearer"},
{Name: "header-user", Group: "ops", APIKey: "sk-header"},
}},
}
a, err := newAuthStore(cfg)
if err != nil {
t.Fatal(err)
}
r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
r.Header.Set("Authorization", "Bearer sk-bearer")
r.Header.Set("x-api-key", "sk-header")
u, _, ok := a.identify(r)
if !ok || u.name != "bearer-user" {
t.Fatalf("want bearer to win, got %v ok=%v", u, ok)
}
}
func TestIdentify_WrongKeyFails(t *testing.T) {
a, err := newAuthStore(testAuthCfg())
if err != nil {
t.Fatal(err)
}
r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
r.Header.Set("Authorization", "Bearer sk-wrong")
if _, _, ok := a.identify(r); ok {
t.Fatal("want identify to fail for wrong key")
}
}
func TestIdentify_MissingHeaderFails(t *testing.T) {
a, err := newAuthStore(testAuthCfg())
if err != nil {
t.Fatal(err)
}
r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
if _, _, ok := a.identify(r); ok {
t.Fatal("want identify to fail with no auth header")
}
}
func TestNewAuthStore_UnknownGroup_ReturnsError(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"eng": {}},
Users: &UsersConfig{Inline: []*UserConfig{{Name: "a", Group: "nonexistent", APIKey: "sk-secret"}}},
}
if _, err := newAuthStore(cfg); err == nil {
t.Fatal("want error for user referencing unknown group")
}
}
// TestNewAuthStore_UnknownGroupInGroupsList_ReturnsError proves an
// unknown group named in UserConfig.Groups (not just Group) is a
// constructor error naming the user and the unknown group, the same
// style as the pre-existing Group-alone check above.
func TestNewAuthStore_UnknownGroupInGroupsList_ReturnsError(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"eng": {}},
Users: &UsersConfig{Inline: []*UserConfig{
{Name: "a", Group: "eng", Groups: []string{"nonexistent"}, APIKey: "sk-secret"},
}},
}
_, err := newAuthStore(cfg)
if err == nil {
t.Fatal("want error for a user referencing an unknown group in Groups")
}
if !strings.Contains(err.Error(), `user "a"`) || !strings.Contains(err.Error(), `unknown group "nonexistent"`) {
t.Errorf("err = %v, want it to name the user and the unknown group", err)
}
}
// TestNewAuthStore_UserWithNoGroupAtAll_ReturnsError proves a user naming
// neither Group nor Groups is a constructor error: at least one group is
// required.
func TestNewAuthStore_UserWithNoGroupAtAll_ReturnsError(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"eng": {}},
Users: &UsersConfig{Inline: []*UserConfig{{Name: "a", APIKey: "sk-secret"}}},
}
if _, err := newAuthStore(cfg); err == nil {
t.Fatal("want error for a user with no group at all")
}
}
// TestBuildEntry_SingleGroupNoPersonalGrant_ReusesSharedGroupPointer is
// the multi-group feature's back-compat rule: a user belonging to exactly
// one group, with no personal Providers/Models grant, resolves to the
// EXACT SAME *group pointer newAuthStore already built for that group —
// identity preserved, not merely equal values.
func TestBuildEntry_SingleGroupNoPersonalGrant_ReusesSharedGroupPointer(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"eng": {Providers: []string{"openai"}}},
Users: &UsersConfig{Inline: []*UserConfig{{Name: "a", Group: "eng", APIKey: "sk-secret"}}},
}
a, err := newAuthStore(cfg)
if err != nil {
t.Fatal(err)
}
_, grp, ok := identifyWithKey(a, "sk-secret")
if !ok {
t.Fatal("identify failed")
}
if grp != a.groups["eng"] {
t.Error("want the exact shared *group pointer for a single-group user with no personal grant")
}
}
// --- LOW (review round 3): personalGrantAllowsPath's own "borrow the
// primary group's paths" rule, isolated and mutation-checked ---
// TestGroup_PersonalGrantAllowsPath_BorrowsPrimaryGroupPaths proves the
// personal grant's own path authorization borrows the PRIMARY member
// group's (memberGroups[0]) own passthroughPaths — never an unconditional
// allow, and never a DIFFERENT member group's list.
func TestGroup_PersonalGrantAllowsPath_BorrowsPrimaryGroupPaths(t *testing.T) {
primary := &group{name: "home", passthroughPaths: []string{"v1/chat/completions"}}
other := &group{name: "friends"}
grp := effectiveGroup([]*group{primary, other}, &grant{providers: []string{"beta"}})
if !grp.personalGrantAllowsPath("v1/chat/completions") {
t.Error("want the personal grant to allow the PRIMARY group's own allowed path")
}
if grp.personalGrantAllowsPath("v1/files") {
t.Error("want the personal grant denied a path the PRIMARY group does not allow — this must not silently become \"always allow\"")
}
}
// TestGroup_PersonalGrantAllowsPath_NoPersonalGrant_ReturnsFalse proves
// the method is not vacuously true for a principal with no personal
// grant at all.
func TestGroup_PersonalGrantAllowsPath_NoPersonalGrant_ReturnsFalse(t *testing.T) {
grp := effectiveGroup([]*group{{name: "a"}, {name: "b"}}, nil)
if grp.personalGrantAllowsPath("anything") {
t.Error("want false: no personal grant exists to borrow a path for")
}
}
// --- LOW-7 (review round 2): test-gap coverage for rules the suite
// stayed green about even after they were reverted ---
// TestUnionOrAll_AnyEmptyList_AllowsAll pins unionOrAll's own contract:
// ONE empty list among several already means "matches anything"
// (matchesGlob's own empty-means-all rule), so the union of everything
// must too, regardless of what the OTHER lists contain.
func TestUnionOrAll_AnyEmptyList_AllowsAll(t *testing.T) {
if got := unionOrAll([]string{"a", "b"}, nil); got != nil {
t.Errorf("unionOrAll([a b], nil) = %v, want nil (one empty list allows everything)", got)
}
if got := unionOrAll([]string{"a"}, []string{}, []string{"c"}); got != nil {
t.Errorf("unionOrAll = %v, want nil (an empty list ANYWHERE in the input allows everything)", got)
}
}
// TestUnionOrAll_NoEmptyLists_ConcatenatesEveryPattern proves the
// positive case: with no empty list among the inputs, the union is
// every pattern from every list, in order.
func TestUnionOrAll_NoEmptyLists_ConcatenatesEveryPattern(t *testing.T) {
got := unionOrAll([]string{"a", "b"}, []string{"c"})
want := []string{"a", "b", "c"}
if len(got) != len(want) {
t.Fatalf("unionOrAll = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("unionOrAll = %v, want %v", got, want)
}
}
}
// TestBuildLimitScopes_DuplicateGroupInGroupsList_CountedOnce proves
// effectiveGroupNames' de-duplication (auth.go) actually matters end to
// end: a user naming the SAME group via both Group and Groups collapses
// to a single-member principal — reusing the shared *group pointer
// (back-compat identity rule) — and buildLimitScopes/snapshot must count
// it exactly once, never twice.
func TestBuildLimitScopes_DuplicateGroupInGroupsList_CountedOnce(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"eng": {}},
Users: &UsersConfig{Inline: []*UserConfig{
{Name: "a", Group: "eng", Groups: []string{"eng"}, APIKey: "sk-secret"},
}},
}
a, err := newAuthStore(cfg)
if err != nil {
t.Fatal(err)
}
u, grp, ok := identifyWithKey(a, "sk-secret")
if !ok {
t.Fatal("identify failed")
}
if grp != a.groups["eng"] {
t.Error("want the exact shared *group pointer — a duplicate group name must collapse to a single-member principal")
}
scopes := buildLimitScopes(u, grp)
if len(scopes) != 2 {
t.Fatalf("len(scopes) = %d, want 2 (1 user + 1 group — a group listed twice must not produce two scopes)", len(scopes))
}
_, groups := a.snapshot()
for _, g := range groups {
if g.name == "eng" && g.memberCount != 1 {
t.Errorf("eng memberCount = %d, want 1 (listing the same group twice must not double-count the user)", g.memberCount)
}
}
}
// TestEffectiveGroup_MCPServersUnion_AnyEmptyMemberAllowsAll and
// TestEffectiveGroup_MCPServersUnion_BothRestricted_ConcatenatesPatterns
// (plus their Agents counterparts, below) pin mcpServers/agents' own
// union rule (effectiveGroup, auth.go) — LOW-7 test-gap coverage,
// mirroring providers/models' own already-covered union tests
// (TestModelRegistry_Resolve_MultiGroup_UnionAcrossMemberGroups) for the
// two fields that stay a plain union (unlike passthroughPaths, HIGH-1).
func TestEffectiveGroup_MCPServersUnion_AnyEmptyMemberAllowsAll(t *testing.T) {
a := &group{name: "a", mcpServers: []string{"search-*"}}
b := &group{name: "b"} // empty: allow-all
grp := effectiveGroup([]*group{a, b}, nil)
if !grp.allowsMCP("anything-at-all") {
t.Errorf("mcpServers = %v, want allow-all (member b's own empty list)", grp.mcpServers)
}
}
func TestEffectiveGroup_MCPServersUnion_BothRestricted_ConcatenatesPatterns(t *testing.T) {
a := &group{name: "a", mcpServers: []string{"search-*"}}
b := &group{name: "b", mcpServers: []string{"db-admin"}}
grp := effectiveGroup([]*group{a, b}, nil)
if !grp.allowsMCP("search-web") || !grp.allowsMCP("db-admin") {
t.Errorf("want both search-web and db-admin allowed, mcpServers=%v", grp.mcpServers)
}
if grp.allowsMCP("other") {
t.Errorf("want \"other\" denied, mcpServers=%v", grp.mcpServers)
}
}
func TestEffectiveGroup_AgentsUnion_AnyEmptyMemberAllowsAll(t *testing.T) {
a := &group{name: "a", agents: []string{"triage"}}
b := &group{name: "b"} // empty: allow-all
grp := effectiveGroup([]*group{a, b}, nil)
if !grp.allowsAgent("anything-at-all") {
t.Errorf("agents = %v, want allow-all (member b's own empty list)", grp.agents)
}
}
func TestEffectiveGroup_AgentsUnion_BothRestricted_ConcatenatesPatterns(t *testing.T) {
a := &group{name: "a", agents: []string{"triage"}}
b := &group{name: "b", agents: []string{"support"}}
grp := effectiveGroup([]*group{a, b}, nil)
if !grp.allowsAgent("triage") || !grp.allowsAgent("support") {
t.Errorf("want both triage and support allowed, agents=%v", grp.agents)
}
if grp.allowsAgent("other") {
t.Errorf("want \"other\" denied, agents=%v", grp.agents)
}
}
// TestNewAuthStore_ModelsOnlyPersonalGrant_ReturnsConstructorError is
// MEDIUM-5's regression (review round 2): a personal grant with Models
// set but Providers left empty means "any provider" (matchesGlob's own
// empty-means-all contract) combined with a model restriction — which
// silently reaches EVERY configured provider, not just the ones the user's
// group(s) already cover, and passes the passthrough provider gate for
// any provider too. This must be rejected at construction, naming the
// user, rather than silently granted.
func TestNewAuthStore_ModelsOnlyPersonalGrant_ReturnsConstructorError(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"eng": {}},
Users: &UsersConfig{Inline: []*UserConfig{
{Name: "a", Group: "eng", APIKey: "sk-secret", Models: []string{"gpt-x"}},
}},
}
_, err := newAuthStore(cfg)
if err == nil {
t.Fatal("want a constructor error for a models-only personal grant (no personal providers)")
}
if !strings.Contains(err.Error(), `user "a"`) {
t.Errorf("err = %v, want it to name the user", err)
}
}
// TestNewAuthStore_ProvidersOnlyPersonalGrant_OK is the positive control:
// a personal grant with Providers set and Models left empty is valid —
// it means "every model on those providers," never a construction error.
func TestNewAuthStore_ProvidersOnlyPersonalGrant_OK(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"eng": {}},
Users: &UsersConfig{Inline: []*UserConfig{
{Name: "a", Group: "eng", APIKey: "sk-secret", Providers: []string{"gx10"}},
}},
}
if _, err := newAuthStore(cfg); err != nil {
t.Fatalf("newAuthStore: %v, want no error for a providers-only personal grant", err)
}
}
// TestAuthStore_Snapshot_MultiGroupUser_ListsAllGroupsAndCountsEachMember
// proves the admin-snapshot side of multi-group support: a user's own
// summary lists every member group (not just the first, which stays for
// back-compat), and each member group's memberCount includes that user
// once — see UserConfig.Groups' own doc comment (llmgateway.go).
func TestAuthStore_Snapshot_MultiGroupUser_ListsAllGroupsAndCountsEachMember(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"eng": {}, "ops": {}},
Users: &UsersConfig{Inline: []*UserConfig{
{Name: "carol", Group: "eng", Groups: []string{"ops"}, APIKey: "sk-carol"},
}},
}
a, err := newAuthStore(cfg)
if err != nil {
t.Fatal(err)
}
users, groups := a.snapshot()
if len(users) != 1 {
t.Fatalf("users = %v, want 1", users)
}
if users[0].groupName != "eng" {
t.Errorf("groupName = %q, want %q (first group, back-compat)", users[0].groupName, "eng")
}
if len(users[0].groups) != 2 || users[0].groups[0] != "eng" || users[0].groups[1] != "ops" {
t.Errorf("groups = %v, want [eng ops]", users[0].groups)
}
counts := map[string]int{}
for _, g := range groups {
counts[g.name] = g.memberCount
}
if counts["eng"] != 1 || counts["ops"] != 1 {
t.Errorf("memberCounts = %v, want eng=1 ops=1", counts)
}
}
// TestAuthStore_Snapshot_SourceInlineVsFile (admin dashboard redesign,
// WP-B) pins userSummary.source: an inline user reports "inline", and a
// file-sourced user — even one that overrides an inline user of the same
// name (replaceFileUsers' own doc comment) — reports "file".
func TestAuthStore_Snapshot_SourceInlineVsFile(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"eng": {}},
Users: &UsersConfig{Inline: []*UserConfig{
{Name: "inline-only", Group: "eng", APIKey: "sk-inline"},
{Name: "overridden", Group: "eng", APIKey: "sk-old"},
}},
}
a, err := newAuthStore(cfg)
if err != nil {
t.Fatal(err)
}
if err := a.replaceFileUsers([]*UserConfig{
{Name: "overridden", Group: "eng", APIKey: "sk-new"},
{Name: "file-only", Group: "eng", APIKey: "sk-file"},
}); err != nil {
t.Fatal(err)
}
users, _ := a.snapshot()
got := map[string]string{}
for _, u := range users {
got[u.name] = u.source
}
want := map[string]string{"inline-only": "inline", "overridden": "file", "file-only": "file"}
for name, wantSource := range want {
if got[name] != wantSource {
t.Errorf("source[%q] = %q, want %q", name, got[name], wantSource)
}
}
}
// TestAuthStore_Snapshot_AdminAndPersonalGrant (admin dashboard redesign,
// WP-B) pins userSummary.admin and personalProviders/personalModels: a
// user with no personal grant reports both nil; a user with one reports
// exactly its own Providers/Models, not the member group's resolved
// access.
func TestAuthStore_Snapshot_AdminAndPersonalGrant(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"eng": {Providers: []string{"anthropic"}}},
Users: &UsersConfig{Inline: []*UserConfig{
{Name: "plain", Group: "eng", APIKey: "sk-plain"},
{Name: "admin-user", Group: "eng", APIKey: "sk-admin", Admin: true},
{Name: "grantee", Group: "eng", APIKey: "sk-grant", Providers: []string{"openai"}, Models: []string{"gpt-5"}},
}},
}
a, err := newAuthStore(cfg)
if err != nil {
t.Fatal(err)
}
users, _ := a.snapshot()
byName := map[string]userSummary{}
for _, u := range users {
byName[u.name] = u
}
if byName["plain"].admin {
t.Error(`users["plain"].admin = true, want false`)
}
if !byName["admin-user"].admin {
t.Error(`users["admin-user"].admin = false, want true`)
}
if byName["plain"].personalProviders != nil || byName["plain"].personalModels != nil {
t.Errorf("plain user's personal grant = (%v, %v), want (nil, nil)", byName["plain"].personalProviders, byName["plain"].personalModels)
}
g := byName["grantee"]
if len(g.personalProviders) != 1 || g.personalProviders[0] != "openai" {
t.Errorf("grantee personalProviders = %v, want [openai]", g.personalProviders)
}
if len(g.personalModels) != 1 || g.personalModels[0] != "gpt-5" {
t.Errorf("grantee personalModels = %v, want [gpt-5]", g.personalModels)
}
}
// TestAuthStore_Snapshot_GroupPassthroughPaths (admin dashboard redesign,
// WP-B) pins groupSummary.passthroughPaths: carried through unchanged
// from GroupConfig.PassthroughPaths, cloned (not aliased) like the other
// access-list fields.
func TestAuthStore_Snapshot_GroupPassthroughPaths(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{
"eng": {PassthroughPaths: []string{"v1/chat/completions"}},
"unrestrict": {},
},
Users: &UsersConfig{Inline: []*UserConfig{
{Name: "a", Group: "eng", APIKey: "sk-a"},
}},
}
a, err := newAuthStore(cfg)
if err != nil {
t.Fatal(err)
}
_, groups := a.snapshot()
byName := map[string]groupSummary{}
for _, g := range groups {
byName[g.name] = g
}
if len(byName["eng"].passthroughPaths) != 1 || byName["eng"].passthroughPaths[0] != "v1/chat/completions" {
t.Errorf(`groups["eng"].passthroughPaths = %v, want ["v1/chat/completions"]`, byName["eng"].passthroughPaths)
}
if byName["unrestrict"].passthroughPaths != nil {
t.Errorf(`groups["unrestrict"].passthroughPaths = %v, want nil`, byName["unrestrict"].passthroughPaths)
}
}
func TestNewAuthStore_DuplicateAPIKey_ReturnsError(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"eng": {}},
Users: &UsersConfig{Inline: []*UserConfig{
{Name: "a", Group: "eng", APIKey: "sk-same"},
{Name: "b", Group: "eng", APIKey: "sk-same"},
}},
}
if _, err := newAuthStore(cfg); err == nil {
t.Fatal("want error for duplicate API key across users")
}
}
func TestNewAuthStore_EmptyAPIKeyAfterResolution_ReturnsError(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"eng": {}},
Users: &UsersConfig{Inline: []*UserConfig{{Name: "a", Group: "eng", APIKey: ""}}},
}
if _, err := newAuthStore(cfg); err == nil {
t.Fatal("want error for user with empty API key after resolution")
}
}
// TestNewAuthStore_NilGroupConfig_ReturnsError covers a Groups map entry
// whose value is a nil *GroupConfig (a caller can construct this in Go, and
// Traefik's own YAML-to-struct decoding can leave a map value nil for an
// empty mapping entry) — newAuthStore must reject it as a constructor error
// rather than panic dereferencing gc.Limits on a nil gc.
func TestNewAuthStore_NilGroupConfig_ReturnsError(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"eng": nil},
}
if _, err := newAuthStore(cfg); err == nil {
t.Fatal("want error for a nil GroupConfig value, got nil")
}
}
func TestNewAuthStore_NoUsers_OK(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
}
a, err := newAuthStore(cfg)
if err != nil {
t.Fatalf("newAuthStore: %v", err)
}
r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
r.Header.Set("x-api-key", "anything")
if _, _, ok := a.identify(r); ok {
t.Fatal("want identify to fail when no users are configured")
}
}
// --- GroupConfig.CacheTTL: constructor validation (item B) ---
// TestNewAuthStore_GroupCacheTTL_ConstructorErrors is the constructor-error
// table for GroupConfig.CacheTTL: a malformed duration, a zero duration, a
// negative duration, and CacheTTL set while the global cache block is not
// configured (nothing to inherit ttl from, the same reasoning
// newAuthStore already applies to Cache:true) must all fail construction.
func TestNewAuthStore_GroupCacheTTL_ConstructorErrors(t *testing.T) {
tests := []struct {
name string
cacheTTL string
globalCache bool
}{
{name: "malformed duration", cacheTTL: "not-a-duration", globalCache: true},
{name: "zero duration", cacheTTL: "0s", globalCache: true},
{name: "negative duration", cacheTTL: "-5s", globalCache: true},
{name: "set without global cache enabled", cacheTTL: "5m", globalCache: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"default": {CacheTTL: tt.cacheTTL}},
Cache: CacheConfig{Enabled: tt.globalCache},
}
if _, err := newAuthStore(cfg); err == nil {
t.Fatalf("newAuthStore: want a constructor error for cacheTTL=%q (globalCache=%v)", tt.cacheTTL, tt.globalCache)
}
})
}
}
// TestNewAuthStore_GroupCacheTTL_EmptyInheritsZero proves an omitted
// CacheTTL resolves to group.cacheTTL == 0 — effectiveTTL's (cache.go)
// signal to inherit the global cache TTL rather than override it.
func TestNewAuthStore_GroupCacheTTL_EmptyInheritsZero(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"default": {}},
}
a, err := newAuthStore(cfg)
if err != nil {
t.Fatalf("newAuthStore: %v", err)
}
if got := a.groups["default"].cacheTTL; got != 0 {
t.Errorf("groups[default].cacheTTL = %v, want 0 (inherit)", got)
}
}
// TestNewAuthStore_GroupCacheTTL_ValidOverride_ResolvedOnGroup proves a
// well-formed, positive CacheTTL with the global cache block configured
// both constructs cleanly and resolves onto the group struct exactly,
// ready for effectiveTTL to read.
func TestNewAuthStore_GroupCacheTTL_ValidOverride_ResolvedOnGroup(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"default": {CacheTTL: "30s"}},
Cache: CacheConfig{Enabled: true},
}
a, err := newAuthStore(cfg)
if err != nil {
t.Fatalf("newAuthStore: %v", err)
}
if got := a.groups["default"].cacheTTL; got != 30*time.Second {
t.Errorf("groups[default].cacheTTL = %v, want 30s", got)
}
}
func TestGroup_AllowsProvider_EmptyListAllowsAll(t *testing.T) {
grp := &group{name: "eng"}
if !grp.allowsProvider("openai") {
t.Fatal("empty providers list should allow all")
}
}
func TestGroup_AllowsProvider_Glob(t *testing.T) {
grp := &group{name: "eng", providers: []string{"open*"}}
if !grp.allowsProvider("openai") {
t.Error("want glob match")
}
if grp.allowsProvider("anthropic") {
t.Error("want no match")
}
}
func TestGroup_AllowsMCP_Glob(t *testing.T) {
grp := &group{name: "eng", mcpServers: []string{"search-*"}}
if !grp.allowsMCP("search-web") {
t.Error("want glob match")
}
if grp.allowsMCP("db-admin") {
t.Error("want no match")
}
}
func TestGroup_AllowsAgent_Glob(t *testing.T) {
grp := &group{name: "eng", agents: []string{"triage"}}
if !grp.allowsAgent("triage") {
t.Error("want exact match")
}
if grp.allowsAgent("other") {
t.Error("want no match")
}
}
// TestGroup_AllowsProviderModel_EmptyListsAllowAnything moved from the
// removed allowsModel (review-auth finding F6, 2026-09 audit): both grp's
// empty Providers and empty Models glob independently mean "allow every
// value", so any (provider, model) pair matches.
func TestGroup_AllowsProviderModel_EmptyListsAllowAnything(t *testing.T) {
grp := &group{name: "eng"}
if !grp.allowsProviderModel("any-provider", "anything-goes") {
t.Fatal("empty providers and models lists should allow anything")
}
}
// TestGroup_AllowsPassthroughPath_EmptyListAllowsAll pins the zero-config
// default for GroupConfig.PassthroughPaths (security+performance audit,
// 2026-08-22): a group with no PassthroughPaths configured — every group
// that existed before this field did — must keep reaching every native
// passthrough path, exactly like allowsProvider/allowsProviderModel/
// allowsMCP/allowsAgent's own empty-means-all defaults.
func TestGroup_AllowsPassthroughPath_EmptyListAllowsAll(t *testing.T) {
grp := &group{name: "eng"}
if !grp.allowsPassthroughPath("v1/files") {
t.Fatal("empty passthroughPaths list should allow all")
}
}
func TestGroup_AllowsPassthroughPath_Glob(t *testing.T) {
grp := &group{name: "eng", passthroughPaths: []string{"v1/chat/completions"}}
if !grp.allowsPassthroughPath("v1/chat/completions") {
t.Error("want exact match")
}
if grp.allowsPassthroughPath("v1/files") {
t.Error("want no match for a path outside the allowlist")
}
}
// TestGroup_AllowsPassthroughPath_RejectsEncodedSlashBypass is the
// security review finding 3 regression test (round 3, 2026-08-22): a
// glob of ["v1/*"] (one wildcard segment) must not be defeated by
// encoding the "/" that would otherwise split the candidate into a
// second segment. Before this fix, matchesGlob ran against the escaped
// form directly, so path.Match("v1/*", "v1/fine_tuning%2Fjobs") reported
// true — the escaped rest looks like exactly two segments to path.Match,
// same as the decoded intent, but MEASURED to actually still match
// because "%2Fjobs" contains no literal "/" for the glob to split on
// differently — while an upstream that itself decodes %2F would read the
// same rest as "v1/fine_tuning/jobs", a THREE-segment path "v1/*" was
// never meant to authorize.
func TestGroup_AllowsPassthroughPath_RejectsEncodedSlashBypass(t *testing.T) {
grp := &group{name: "eng", passthroughPaths: []string{"v1/*"}}
if grp.allowsPassthroughPath("v1/fine_tuning%2Fjobs") {
t.Error("want no match: the decoded form is three segments (v1/fine_tuning/jobs), which \"v1/*\" does not authorize")
}
// Sanity: the decoded-equivalent form is independently denied too,
// proving the glob genuinely does not authorize the deeper path — not
// just that encoding happens to break matching by accident.
if grp.allowsPassthroughPath("v1/fine_tuning/jobs") {
t.Error("want no match for the literal three-segment form either")
}
// A genuinely single-segment encoded value must still match: "v1/*"
// authorizes any ONE segment after "v1", encoded or not.
if !grp.allowsPassthroughPath("v1/chat%2Dcompletions") {
t.Error("want match: a single encoded segment (no literal slash once decoded) is still one segment")
}
}
// TestGroup_AllowsPassthroughPath_MalformedEncoding_DeniesWhenRestricted
// mirrors hasTraversalSegment's own "fails to unescape at all is
// rejected too" rule (routes_passthrough.go) — applied here to the
// allowlist match itself, for a group that actually restricts paths.
func TestGroup_AllowsPassthroughPath_MalformedEncoding_DeniesWhenRestricted(t *testing.T) {
grp := &group{name: "eng", passthroughPaths: []string{"v1/*"}}
if grp.allowsPassthroughPath("v1/%zz") {
t.Error("want no match: malformed percent-encoding must fail closed for a restricted group")
}
}
// TestGroup_AllowsPassthroughPath_MalformedEncoding_StillAllowsWhenUnrestricted
// pins the zero-config default-preserving requirement precisely at the
// boundary this fix touches: an EMPTY PassthroughPaths list (the fast
// path, checked before any decoding) must keep allowing a request whose
// rest fails to percent-decode — exactly as before this finding, since
// hasTraversalSegment (unconditional, in handlePassthrough) is still the
// gate for that case, unchanged by this fix.
func TestGroup_AllowsPassthroughPath_MalformedEncoding_StillAllowsWhenUnrestricted(t *testing.T) {
grp := &group{name: "eng"} // no PassthroughPaths configured
if !grp.allowsPassthroughPath("v1/%zz") {
t.Error("want match: an unrestricted group's fast path must never decode rest at all")
}
}
func TestAuthStore_ReplaceFileUsers_AddsAndSwapsUsers(t *testing.T) {
a, err := newAuthStore(testAuthCfg()) // inline user "a" / sk-secret in group eng
if err != nil {
t.Fatal(err)
}
if err := a.replaceFileUsers([]*UserConfig{{Name: "f1", Group: "eng", APIKey: "sk-file1"}}); err != nil {
t.Fatalf("replaceFileUsers: %v", err)
}
inlineReq := httptest.NewRequest("POST", "/v1/chat/completions", nil)
inlineReq.Header.Set("Authorization", "Bearer sk-secret")
if _, _, ok := a.identify(inlineReq); !ok {
t.Fatal("want inline user to remain identifiable after replaceFileUsers")
}
fileReq := httptest.NewRequest("POST", "/v1/chat/completions", nil)
fileReq.Header.Set("Authorization", "Bearer sk-file1")
u, _, ok := a.identify(fileReq)
if !ok || u.name != "f1" {
t.Fatalf("want file user f1 identifiable, got %v ok=%v", u, ok)
}
// Second call swaps the file-sourced set wholesale: f1 must disappear,
// f2 must appear, inline user must still work.
if err := a.replaceFileUsers([]*UserConfig{{Name: "f2", Group: "eng", APIKey: "sk-file2"}}); err != nil {
t.Fatalf("replaceFileUsers (second): %v", err)
}
if _, _, ok := a.identify(fileReq); ok {
t.Fatal("want f1 to no longer be identifiable after being replaced")
}
if _, _, ok := a.identify(inlineReq); !ok {
t.Fatal("want inline user to remain identifiable after second replaceFileUsers")
}
f2Req := httptest.NewRequest("POST", "/v1/chat/completions", nil)
f2Req.Header.Set("Authorization", "Bearer sk-file2")
if _, _, ok := a.identify(f2Req); !ok {
t.Fatal("want file user f2 identifiable after second replaceFileUsers")
}
}
func TestAuthStore_ReplaceFileUsers_DuplicateWithInline_ReturnsErrorAndLeavesStoreUnchanged(t *testing.T) {
a, err := newAuthStore(testAuthCfg()) // inline user "a" / sk-secret
if err != nil {
t.Fatal(err)
}
err = a.replaceFileUsers([]*UserConfig{{Name: "dup", Group: "eng", APIKey: "sk-secret"}})
if err == nil {
t.Fatal("want error for file user colliding with inline user's API key")
}
// Store must still be usable and unchanged by the rejected replace.
r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
r.Header.Set("Authorization", "Bearer sk-secret")
u, _, ok := a.identify(r)
if !ok || u.name != "a" {
t.Fatalf("want original inline user still resolvable after rejected replace, got %v ok=%v", u, ok)
}
}
func TestAuthStore_ReplaceFileUsers_UnknownGroup_ReturnsError(t *testing.T) {
a, err := newAuthStore(testAuthCfg())
if err != nil {
t.Fatal(err)
}
err = a.replaceFileUsers([]*UserConfig{{Name: "f1", Group: "nonexistent", APIKey: "sk-file1"}})
if err == nil {
t.Fatal("want error for file user referencing unknown group")
}
}
// TestAuthStore_ReplaceFileUsers_NilElement_ReturnsErrorNotPanic is the
// regression for a nil *UserConfig slice element reaching buildEntry. This
// path is reachable at runtime: json.Unmarshal of a `null` array element
// into []*UserConfig yields a nil entry, and the config-reload path (Task 4)
// that calls replaceFileUsers is NOT wrapped by recoverPanic — that guard
// only covers ServeHTTP. An unguarded nil dereference here would take down
// the whole process instead of failing one reload.
func TestAuthStore_ReplaceFileUsers_NilElement_ReturnsErrorNotPanic(t *testing.T) {
a, err := newAuthStore(testAuthCfg())
if err != nil {
t.Fatal(err)
}
if err := a.replaceFileUsers([]*UserConfig{nil}); err == nil {
t.Fatal("want error for a nil user config element, got nil")
}
// The store must still be usable — the rejected replace must not have
// partially mutated it.
r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
r.Header.Set("Authorization", "Bearer sk-secret")
if _, _, ok := a.identify(r); !ok {
t.Fatal("want inline user still resolvable after a rejected nil-element replace")
}
}
// TestNewAuthStore_NilInlineElement_ReturnsErrorNotPanic covers the same
// nil-element hazard reachable from newAuthStore's inline-user loop, which
// runs at plugin construction time — also outside recoverPanic's coverage.
func TestNewAuthStore_NilInlineElement_ReturnsErrorNotPanic(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"eng": {}},
Users: &UsersConfig{Inline: []*UserConfig{nil}},
}
if _, err := newAuthStore(cfg); err == nil {
t.Fatal("want error for a nil inline user config element, got nil")
}
}
// TestAuthStore_ReplaceFileUsers_ConcurrentWritersAndReaders exercises
// replaceFileUsers and identify from many goroutines simultaneously under
// -race. Each writer swaps in a single, uniquely-keyed file user, so
// whichever writer's rebuild is applied last, the store must always hold
// exactly the inline users plus one file user's worth of entries — never a
// partial mix from two overlapping rebuilds, and never a state where the
// always-present inline user briefly disappears.
func TestAuthStore_ReplaceFileUsers_ConcurrentWritersAndReaders(t *testing.T) {
a, err := newAuthStore(testAuthCfg()) // inline user "a" / sk-secret, group eng
if err != nil {
t.Fatal(err)
}
const writers = 20
const readers = 10
const readsPerReader = 50
var wg sync.WaitGroup
wg.Add(writers + readers)
for i := 0; i < writers; i++ {
go func(i int) {
defer wg.Done()
uc := &UserConfig{Name: fmt.Sprintf("f%d", i), Group: "eng", APIKey: fmt.Sprintf("sk-file-%d", i)}
if err := a.replaceFileUsers([]*UserConfig{uc}); err != nil {
t.Errorf("replaceFileUsers(%d): %v", i, err)
}
}(i)
}
for i := 0; i < readers; i++ {
go func() {
defer wg.Done()
r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
r.Header.Set("Authorization", "Bearer sk-secret")
for j := 0; j < readsPerReader; j++ {
if _, _, ok := a.identify(r); !ok {
t.Error("inline user must remain identifiable during concurrent file-user swaps")
return
}
}
}()
}
wg.Wait()
a.mu.RLock()
got := len(a.byDigest)
a.mu.RUnlock()
want := len(a.inline) + 1 // inline users plus exactly one winning file-user batch
if got != want {
t.Fatalf("want exactly one file-user batch to survive concurrent swaps (size %d), got size %d — a rebuild was lost or merged with another", want, got)
}
}
// TestAuthStore_Identify_NotBlockedByBuildMu is the regression for the
// Task-4 lock split: replaceFileUsers's writer-serialization lock (buildMu)
// must be independent from the reader lock (mu) that identify uses. The
// prior implementation held mu for the whole rebuild, which would stall
// every identify call for as long as a reload's build phase (including
// resolveSecret's potentially slow file-backed reads) took. Holding buildMu
// externally here simulates "a rebuild's build phase is in progress" — a
// correct identify must not need buildMu at all.
func TestAuthStore_Identify_NotBlockedByBuildMu(t *testing.T) {
a, err := newAuthStore(testAuthCfg())
if err != nil {
t.Fatal(err)
}
a.buildMu.Lock()
defer a.buildMu.Unlock()
r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
r.Header.Set("Authorization", "Bearer sk-secret")
done := make(chan bool, 1)
go func() {
_, _, ok := a.identify(r)
done <- ok
}()
select {
case ok := <-done:
if !ok {
t.Fatal("want identify to succeed")
}
case <-time.After(2 * time.Second):
t.Fatal("identify blocked while buildMu is held — reader lock must not depend on the writer-serialization lock")
}
}
// --- security audit finding 3: per-source-IP auth-failure throttling ---
func TestClientIP_StripsPort(t *testing.T) {
r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
r.RemoteAddr = "203.0.113.9:54321"
if got := clientIP(r); got != "203.0.113.9" {
t.Errorf("clientIP = %q, want %q", got, "203.0.113.9")
}
}
func TestClientIP_MalformedRemoteAddr_UsedAsIs(t *testing.T) {
r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
r.RemoteAddr = "not-a-host-port"
if got := clientIP(r); got != "not-a-host-port" {
t.Errorf("clientIP = %q, want the raw RemoteAddr back unchanged", got)
}
}
// TestAuthStore_Identify_ValidKeyAlwaysSucceeds_EvenWhenIPThrottled is the
// core regression for security review round 2's critical finding 1: this
// plugin runs as a Traefik middleware behind a Kubernetes Service — with
// externalTrafficPolicy: Cluster, r.RemoteAddr is the NODE's own SNAT
// address, shared by every external caller reaching that node, not a
// per-tenant one. The prior revision of identify checked the per-IP
// throttle BEFORE verifying the key, so 20 failed requests from anywhere
// sharing that node locked out every legitimate tenant too. After well
// past authFailureLimit failed attempts from one source IP, a SUBSEQUENT
// VALID key from that EXACT same IP must still succeed immediately.
func TestAuthStore_Identify_ValidKeyAlwaysSucceeds_EvenWhenIPThrottled(t *testing.T) {
a, err := newAuthStore(testAuthCfg()) // inline user "a" / sk-secret, group eng
if err != nil {
t.Fatal(err)
}
const sharedIP = "203.0.113.9:54321"
for i := 0; i < authFailureLimit*2; i++ { // well past the threshold
r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
r.RemoteAddr = sharedIP
r.Header.Set("x-api-key", "wrong-key")
a.identify(r)
}
// Sanity: the tracker really did cross the threshold — otherwise this
// test would not actually exercise the throttled-IP path at all.
if !a.authThrottled("203.0.113.9") {
t.Fatal("test setup: expected the tracker to be over authFailureLimit for the shared IP")
}
valid := httptest.NewRequest("POST", "/v1/chat/completions", nil)
valid.RemoteAddr = sharedIP
valid.Header.Set("Authorization", "Bearer sk-secret")
u, grp, ok := a.identify(valid)
if !ok || u.name != "a" || grp.name != "eng" {
t.Fatalf("want a valid key to succeed even from a throttled source IP, got user=%v group=%v ok=%v", u, grp, ok)
}
}
// TestAuthStore_AuthThrottled_TracksPerIPIndependently proves
// authFailureTracker's own counting (used only by logAuthEvent's distinct
// throttle-engaged line now, logger.go — never by identify's return
// value, see identify's own doc comment) is still scoped per source IP:
// one IP crossing the threshold must never affect a different IP's own
// count.
func TestAuthStore_AuthThrottled_TracksPerIPIndependently(t *testing.T) {
a, err := newAuthStore(testAuthCfg())
if err != nil {
t.Fatal(err)
}
for i := 0; i < authFailureLimit; i++ {
r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
r.RemoteAddr = "203.0.113.9:1"
r.Header.Set("x-api-key", "wrong-key")
a.identify(r)
}
if !a.authThrottled("203.0.113.9") {
t.Error("want 203.0.113.9 throttled after authFailureLimit failures")
}
if a.authThrottled("198.51.100.5") {
t.Error("want a different, never-failing IP to remain unthrottled")
}
}
// TestAuthStore_AuthThrottled_ResetsAfterWindowExpires proves
// authFailureTracker is a fixed-window counter, not a permanent record:
// once authFailureWindow has elapsed, a source IP's earlier failures no
// longer count toward authThrottled.
func TestAuthStore_AuthThrottled_ResetsAfterWindowExpires(t *testing.T) {
a, err := newAuthStore(testAuthCfg())
if err != nil {
t.Fatal(err)
}
clock := &fakeClock{now: time.Unix(1_700_000_000, 0)}
a.nowFn = clock.Now
for i := 0; i < authFailureLimit; i++ {
r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
r.RemoteAddr = "203.0.113.9:1"
r.Header.Set("x-api-key", "wrong-key")
a.identify(r)
}
if !a.authThrottled("203.0.113.9") {
t.Fatal("want throttled before the window elapses")
}
clock.Advance(authFailureWindow + time.Second)
if a.authThrottled("203.0.113.9") {
t.Error("want the throttle cleared once authFailureWindow has elapsed")
}
}
// --- authFailureTracker: direct unit tests ---
func TestAuthFailureTracker_IncrementAndGet(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
tr := newAuthFailureTracker(func() time.Time { return now })
if got := tr.get("1.2.3.4"); got != 0 {
t.Errorf("get on untracked ip = %d, want 0", got)
}
for i := int64(1); i <= 5; i++ {
if got := tr.increment("1.2.3.4"); got != i {
t.Errorf("increment #%d = %d, want %d", i, got, i)
}
}
if got := tr.get("1.2.3.4"); got != 5 {
t.Errorf("get after 5 increments = %d, want 5", got)
}
}
func TestAuthFailureTracker_WindowExpiry(t *testing.T) {
clock := &fakeClock{now: time.Unix(1_700_000_000, 0)}
tr := newAuthFailureTracker(clock.Now)
tr.increment("1.2.3.4")
tr.increment("1.2.3.4")
if got := tr.get("1.2.3.4"); got != 2 {
t.Fatalf("get = %d, want 2", got)
}
clock.Advance(authFailureWindow + time.Second)
if got := tr.get("1.2.3.4"); got != 0 {
t.Errorf("get after window expiry = %d, want 0", got)
}
if got := tr.increment("1.2.3.4"); got != 1 {
t.Errorf("increment after window expiry = %d, want 1 (fresh window)", got)
}
}
// TestAuthFailureTracker_BoundsMemoryViaRotation is the regression for
// security review round 2's important finding 1: the tracker must never
// grow past 2×authFailureMapCap total entries — filling it past
// authFailureMapCap distinct IPs must trigger an O(1) generation
// rotation, not unbounded growth.
func TestAuthFailureTracker_BoundsMemoryViaRotation(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
tr := newAuthFailureTracker(func() time.Time { return now })
for i := 0; i < authFailureMapCap+10; i++ {
tr.increment(fmt.Sprintf("10.%d.%d.%d", i/65536, (i/256)%256, i%256))
}
tr.mu.Lock()
total := len(tr.current) + len(tr.previous)
currentLen := len(tr.current)
tr.mu.Unlock()
if total > 2*authFailureMapCap {
t.Errorf("tracked entries = %d, want <= %d (2x cap)", total, 2*authFailureMapCap)
}
if currentLen > authFailureMapCap {
t.Errorf("current generation = %d entries, want <= %d", currentLen, authFailureMapCap)
}
}
// TestAuthStore_Identify_FewFailedAttempts_NeverThrottled is the good-path
// counterpart: a handful of failed attempts (well under authFailureLimit
// — a human retrying a mistyped key a couple of times) must never trip
// the throttle.
func TestAuthStore_Identify_FewFailedAttempts_NeverThrottled(t *testing.T) {
a, err := newAuthStore(testAuthCfg())
if err != nil {
t.Fatal(err)
}
const ip = "203.0.113.9:54321"
for i := 0; i < 3; i++ {
r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
r.RemoteAddr = ip
r.Header.Set("x-api-key", "wrong-key")
a.identify(r)
}
r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
r.RemoteAddr = ip
r.Header.Set("Authorization", "Bearer sk-secret")
if _, _, ok := a.identify(r); !ok {
t.Fatal("want a valid key to succeed after only 3 prior failures (well under authFailureLimit)")
}
}
// TestAuthStore_Identify_SuccessfulAuthDoesNotRecordFailure proves a
// successful identify never itself counts toward the failure budget —
// otherwise a legitimate, high-traffic user could eventually throttle
// themselves.
func TestAuthStore_Identify_SuccessfulAuthDoesNotRecordFailure(t *testing.T) {
a, err := newAuthStore(testAuthCfg())
if err != nil {
t.Fatal(err)
}
const ip = "203.0.113.9:54321"
r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
r.RemoteAddr = ip
r.Header.Set("Authorization", "Bearer sk-secret")
for i := 0; i < authFailureLimit+5; i++ {
if _, _, ok := a.identify(r); !ok {
t.Fatalf("attempt %d: want success (valid key), got failure", i)
}
}
}
// --- security audit finding 5: duplicate/empty user names ---
func TestNewAuthStore_EmptyUserName_ReturnsError(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"eng": {}},
Users: &UsersConfig{Inline: []*UserConfig{{Name: "", Group: "eng", APIKey: "sk-secret"}}},
}
if _, err := newAuthStore(cfg); err == nil {
t.Fatal("want error for an empty user name")
}
}
func TestNewAuthStore_DuplicateUserName_ReturnsError(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"eng": {}},
Users: &UsersConfig{Inline: []*UserConfig{
{Name: "dup", Group: "eng", APIKey: "sk-one"},
{Name: "dup", Group: "eng", APIKey: "sk-two"},
}},
}
_, err := newAuthStore(cfg)
if err == nil {
t.Fatal("want error for two inline users sharing the same name")
}
if !strings.Contains(err.Error(), "duplicate user name") {
t.Errorf("error = %q, want it to mention the duplicate-name check specifically (not the API-key one)", err.Error())
}
}
func TestAuthStore_ReplaceFileUsers_EmptyUserName_ReturnsError(t *testing.T) {
a, err := newAuthStore(testAuthCfg())
if err != nil {
t.Fatal(err)
}
if err := a.replaceFileUsers([]*UserConfig{{Name: "", Group: "eng", APIKey: "sk-file1"}}); err == nil {
t.Fatal("want error for a file user with an empty name")
}
}
func TestAuthStore_ReplaceFileUsers_DuplicateUserNameWithinFile_ReturnsError(t *testing.T) {
a, err := newAuthStore(testAuthCfg())
if err != nil {
t.Fatal(err)
}
err = a.replaceFileUsers([]*UserConfig{
{Name: "dup", Group: "eng", APIKey: "sk-file1"},
{Name: "dup", Group: "eng", APIKey: "sk-file2"},
})
if err == nil {
t.Fatal("want error for two file users sharing the same name")
}
if !strings.Contains(err.Error(), "duplicate user name") {
t.Errorf("error = %q, want it to mention the duplicate-name check specifically (not the API-key one)", err.Error())
}
// The store must still be usable — the rejected replace must not have
// partially mutated it.
r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
r.Header.Set("Authorization", "Bearer sk-secret")
if _, _, ok := a.identify(r); !ok {
t.Fatal("want the original inline user still resolvable after a rejected duplicate-name replace")
}
}
// TestAuthStore_ReplaceFileUsers_SameNameAsInline_DifferentKey_OverrideAllowed
// proves the new duplicate-name check does NOT reject the intentional
// cross-source override replaceFileUsers' own doc comment describes: a
// file user of the same name as an inline user, even with a different API
// key, must still win — this is a feature (rotating/promoting a
// statically-configured user through the hot-reloadable file), not a
// duplicate.
func TestAuthStore_ReplaceFileUsers_SameNameAsInline_DifferentKey_OverrideAllowed(t *testing.T) {
a, err := newAuthStore(testAuthCfg()) // inline user "a" / sk-secret, group eng
if err != nil {
t.Fatal(err)
}
if err := a.replaceFileUsers([]*UserConfig{{Name: "a", Group: "eng", APIKey: "sk-file-a"}}); err != nil {
t.Fatalf("replaceFileUsers: want the same-name override allowed, got error: %v", err)
}
inlineReq := httptest.NewRequest("POST", "/v1/chat/completions", nil)
inlineReq.Header.Set("Authorization", "Bearer sk-secret")
if _, _, ok := a.identify(inlineReq); ok {
t.Fatal("want the inline key overridden once the file defines a same-named user")
}
fileReq := httptest.NewRequest("POST", "/v1/chat/completions", nil)
fileReq.Header.Set("Authorization", "Bearer sk-file-a")
u, _, ok := a.identify(fileReq)
if !ok || u.name != "a" {
t.Fatalf("want the file-sourced user resolvable under the overridden name, got %v ok=%v", u, ok)
}
}