Files
traefik-llmgateway/users_file_test.go
T
lukaszraczylo eef33b8232 feat: users file with throttled hot reload
Add usersFile (newUsersFile/load) for the JSON users-file shape
{"users":[{name,group,apiKey,limits?}]}, and authStore.maybeReload
for throttled hot reload: checks mtime at most every reloadEvery
(5s), reloads on change, keeps the last good set and logs via
errorf on any error (stat, parse, or a rejected replace), and logs
via logf when the reload changes the file-sourced user count.
nowFn is injectable for tests.

Wire-up in llmgateway.go: newGateway does a synchronous initial
load when Users.File is set (a bad initial file fails the
constructor); ServeHTTP calls g.auth.maybeReload() at entry
(no-op when no file is configured).

Also fixes replaceFileUsers's merge semantics: a file user now
overrides an inline user of the same name, even when their keys
differ, dropping the inline entry from the rebuilt set. The prior
digest-only keying let a same-named inline/file pair with
different keys coexist instead of the file version winning.
2026-08-20 01:07:31 +01:00

311 lines
9.3 KiB
Go

package traefikllmgateway
import (
"encoding/json"
"fmt"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing"
"time"
)
// --- test helpers shared with llmgateway_test.go ---
// writeUsersDoc marshals users to the {"users":[...]} shape and writes it to
// fp. When mtime is non-zero, it also forces the file's mtime to that value
// via os.Chtimes — tests use this to make an mtime change unambiguous
// regardless of filesystem timestamp resolution, instead of relying on real
// wall-clock write timing.
func writeUsersDoc(t *testing.T, fp string, users []*UserConfig, mtime time.Time) {
t.Helper()
doc := struct {
Users []*UserConfig `json:"users"`
}{Users: users}
b, err := json.Marshal(doc)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
if err := os.WriteFile(fp, b, 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if !mtime.IsZero() {
if err := os.Chtimes(fp, mtime, mtime); err != nil {
t.Fatalf("Chtimes: %v", err)
}
}
}
// writeUsersFile creates a new users file under dir/name and returns its path.
func writeUsersFile(t *testing.T, dir, name string, users []*UserConfig) string {
t.Helper()
fp := filepath.Join(dir, name)
writeUsersDoc(t, fp, users, time.Time{})
return fp
}
// identifyWithKey builds a Bearer-authenticated request for key and runs it
// through a.identify.
func identifyWithKey(a *authStore, key string) (*user, *group, bool) {
r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
r.Header.Set("Authorization", "Bearer "+key)
return a.identify(r)
}
// stubLogger is a gatewayLogger that records calls instead of writing to
// stderr, for tests that exercise maybeReload directly.
type stubLogger struct {
infos []string
errors []string
mu sync.Mutex
}
func (s *stubLogger) logf(format string, args ...any) {
s.mu.Lock()
defer s.mu.Unlock()
s.infos = append(s.infos, fmt.Sprintf(format, args...))
}
func (s *stubLogger) errorf(format string, args ...any) {
s.mu.Lock()
defer s.mu.Unlock()
s.errors = append(s.errors, fmt.Sprintf(format, args...))
}
func (s *stubLogger) infoCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.infos)
}
func (s *stubLogger) errorCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.errors)
}
// fakeClock is an injectable, manually-advanced clock for nowFn.
type fakeClock struct {
now time.Time
mu sync.Mutex
}
func (c *fakeClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.now
}
func (c *fakeClock) Advance(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.now = c.now.Add(d)
}
// newReloadableAuthStore builds an authStore with inline users, performs the
// same synchronous initial load newGateway would perform against a users
// file seeded with initialFileUsers, and wires a stub logger plus a fake
// clock for throttle control.
func newReloadableAuthStore(t *testing.T, inline, initialFileUsers []*UserConfig) (a *authStore, fp string, log *stubLogger, clock *fakeClock) {
t.Helper()
dir := t.TempDir()
fp = writeUsersFile(t, dir, "users.json", initialFileUsers)
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"eng": {}},
Users: &UsersConfig{Inline: inline},
}
a, err := newAuthStore(cfg)
if err != nil {
t.Fatalf("newAuthStore: %v", err)
}
uf := newUsersFile(fp)
initial, err := uf.load()
if err != nil {
t.Fatalf("initial load: %v", err)
}
if err := a.replaceFileUsers(initial); err != nil {
t.Fatalf("initial replaceFileUsers: %v", err)
}
clock = &fakeClock{now: time.Unix(1_700_000_000, 0)}
log = &stubLogger{}
a.usersFile = uf
a.log = log
a.nowFn = clock.Now
if info, statErr := os.Stat(fp); statErr == nil {
a.lastModTime = info.ModTime()
}
a.lastCheck = clock.Now()
return a, fp, log, clock
}
// --- usersFile.load ---
func TestUsersFile_Load_ValidFile(t *testing.T) {
dir := t.TempDir()
fp := writeUsersFile(t, dir, "users.json", []*UserConfig{
{Name: "alice", Group: "eng", APIKey: "sk-alice", Limits: &LimitsConfig{RequestsPerDay: 100}},
})
uf := newUsersFile(fp)
users, err := uf.load()
if err != nil {
t.Fatalf("load: %v", err)
}
if len(users) != 1 {
t.Fatalf("want 1 user, got %d", len(users))
}
got := users[0]
if got.Name != "alice" || got.Group != "eng" || got.APIKey != "sk-alice" {
t.Fatalf("unexpected user: %+v", got)
}
if got.Limits == nil || got.Limits.RequestsPerDay != 100 {
t.Fatalf("want limits.requestsPerDay=100, got %+v", got.Limits)
}
}
func TestUsersFile_Load_MalformedJSON_ReturnsError(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)
}
uf := newUsersFile(fp)
if _, err := uf.load(); err == nil {
t.Fatal("want error for malformed JSON")
}
}
func TestUsersFile_Load_MissingFile_ReturnsError(t *testing.T) {
uf := newUsersFile(filepath.Join(t.TempDir(), "nope.json"))
if _, err := uf.load(); err == nil {
t.Fatal("want error for a missing file")
}
}
// --- authStore.maybeReload ---
func TestAuthStore_MaybeReload_NoFileConfigured_NoOp(t *testing.T) {
a, err := newAuthStore(testAuthCfg())
if err != nil {
t.Fatal(err)
}
a.maybeReload() // must not panic when a.usersFile is nil
if _, _, ok := identifyWithKey(a, "sk-secret"); !ok {
t.Fatal("want inline user still identifiable after a no-op maybeReload")
}
}
func TestAuthStore_MaybeReload_WithinThrottleWindow_DoesNotReload(t *testing.T) {
a, fp, _, clock := newReloadableAuthStore(t, nil, []*UserConfig{{Name: "f1", Group: "eng", APIKey: "sk-f1"}})
writeUsersDoc(t, fp, []*UserConfig{{Name: "f2", Group: "eng", APIKey: "sk-f2"}}, time.Now().Add(time.Hour))
clock.Advance(reloadEvery - time.Second)
a.maybeReload()
if _, _, ok := identifyWithKey(a, "sk-f1"); !ok {
t.Fatal("want f1 still identifiable before the throttle window elapses")
}
if _, _, ok := identifyWithKey(a, "sk-f2"); ok {
t.Fatal("want f2 not yet identifiable before the throttle window elapses")
}
}
func TestAuthStore_MaybeReload_AfterThrottleWindow_PicksUpMtimeChange(t *testing.T) {
a, fp, _, clock := newReloadableAuthStore(t, nil, []*UserConfig{{Name: "f1", Group: "eng", APIKey: "sk-f1"}})
writeUsersDoc(t, fp, []*UserConfig{{Name: "f2", Group: "eng", APIKey: "sk-f2"}}, time.Now().Add(time.Hour))
clock.Advance(reloadEvery + time.Second)
a.maybeReload()
if _, _, ok := identifyWithKey(a, "sk-f2"); !ok {
t.Fatal("want f2 identifiable once the throttle window elapses and mtime changed")
}
if _, _, ok := identifyWithKey(a, "sk-f1"); ok {
t.Fatal("want f1 no longer identifiable once the reload replaced the file-sourced set")
}
}
func TestAuthStore_MaybeReload_MalformedJSON_KeepsLastGoodSetAndLogsError(t *testing.T) {
a, fp, log, clock := newReloadableAuthStore(t, nil, []*UserConfig{{Name: "f1", Group: "eng", APIKey: "sk-f1"}})
if err := os.WriteFile(fp, []byte("{not json"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
future := time.Now().Add(time.Hour)
if err := os.Chtimes(fp, future, future); err != nil {
t.Fatalf("Chtimes: %v", err)
}
clock.Advance(reloadEvery + time.Second)
a.maybeReload()
if _, _, ok := identifyWithKey(a, "sk-f1"); !ok {
t.Fatal("want the last good file-sourced set retained after a malformed reload")
}
if log.errorCount() == 0 {
t.Fatal("want an errorf call recorded for the malformed reload")
}
}
func TestAuthStore_MaybeReload_UnchangedMtime_DoesNotReload(t *testing.T) {
a, fp, log, clock := newReloadableAuthStore(t, nil, []*UserConfig{{Name: "f1", Group: "eng", APIKey: "sk-f1"}})
_ = fp
clock.Advance(reloadEvery + time.Second)
a.maybeReload()
if _, _, ok := identifyWithKey(a, "sk-f1"); !ok {
t.Fatal("want f1 still identifiable when the file did not change")
}
if log.infoCount() != 0 {
t.Fatal("want no count-change log when the file did not change")
}
}
func TestAuthStore_MaybeReload_UserCountChange_LogsViaLogf(t *testing.T) {
a, fp, log, clock := newReloadableAuthStore(t, nil, []*UserConfig{{Name: "f1", Group: "eng", APIKey: "sk-f1"}})
writeUsersDoc(t, fp, []*UserConfig{
{Name: "f1", Group: "eng", APIKey: "sk-f1"},
{Name: "f2", Group: "eng", APIKey: "sk-f2"},
}, time.Now().Add(time.Hour))
clock.Advance(reloadEvery + time.Second)
a.maybeReload()
if log.infoCount() == 0 {
t.Fatal("want a logf call recorded for the user-count change (1 -> 2)")
}
}
// TestAuthStore_MaybeReload_FileUserSameNameAsInline_FileWins is the merge
// semantic required by Task 4: a file user overrides an inline user of the
// same name, even when their API keys differ. The inline entry's key must
// stop working and the file entry's key must resolve to the file's data.
func TestAuthStore_MaybeReload_FileUserSameNameAsInline_FileWins(t *testing.T) {
inline := []*UserConfig{{Name: "alice", Group: "eng", APIKey: "sk-inline-alice"}}
a, fp, _, clock := newReloadableAuthStore(t, inline, nil)
writeUsersDoc(t, fp, []*UserConfig{{Name: "alice", Group: "eng", APIKey: "sk-file-alice"}}, time.Now().Add(time.Hour))
clock.Advance(reloadEvery + time.Second)
a.maybeReload()
if _, _, ok := identifyWithKey(a, "sk-inline-alice"); ok {
t.Fatal("want the inline alice overridden once the file defines a same-named user")
}
u, _, ok := identifyWithKey(a, "sk-file-alice")
if !ok || u.name != "alice" {
t.Fatalf("want file alice identifiable, got %v ok=%v", u, ok)
}
}