mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-18 04:10:51 +08:00
test(watcher): add comprehensive unit tests for watcher edge cases
Add extensive test coverage for watcher module including: - Auth file handling for empty and missing files - Persist async error paths and nil receiver handling - Dispatch loop context cancellation scenarios - Event processing for errors and channel closures - Handle event cases: unrelated files, config changes, auth writes, remove debouncing, atomic replace detection - Normalize auth path and debounce cleanup logic - Runtime auth dispatch and refresh state - Config reload with mirrored auth dir and OAuth provider filtering - Start failure when auth dir is missing - Auth equality comparison ignoring temporal fields - Reload clients filtering without full rescan
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,6 +17,7 @@ import (
|
|||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/synthesizer"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/synthesizer"
|
||||||
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
@@ -489,6 +491,28 @@ func TestAuthFileUnchangedUsesHash(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthFileUnchangedEmptyAndMissing(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
emptyFile := filepath.Join(tmpDir, "empty.json")
|
||||||
|
if err := os.WriteFile(emptyFile, []byte(""), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write empty auth file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := &Watcher{lastAuthHashes: make(map[string]string)}
|
||||||
|
unchanged, err := w.authFileUnchanged(emptyFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error for empty file: %v", err)
|
||||||
|
}
|
||||||
|
if unchanged {
|
||||||
|
t.Fatal("expected empty file to be treated as changed")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = w.authFileUnchanged(filepath.Join(tmpDir, "missing.json"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing auth file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReloadClientsCachesAuthHashes(t *testing.T) {
|
func TestReloadClientsCachesAuthHashes(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
authFile := filepath.Join(tmpDir, "one.json")
|
authFile := filepath.Join(tmpDir, "one.json")
|
||||||
@@ -528,6 +552,23 @@ func TestReloadClientsLogsConfigDiffs(t *testing.T) {
|
|||||||
w.reloadClients(false, nil, false)
|
w.reloadClients(false, nil, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReloadClientsHandlesNilConfig(t *testing.T) {
|
||||||
|
w := &Watcher{}
|
||||||
|
w.reloadClients(true, nil, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReloadClientsFiltersProvidersWithNilCurrentAuths(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
w := &Watcher{
|
||||||
|
authDir: tmp,
|
||||||
|
config: &config.Config{AuthDir: tmp},
|
||||||
|
}
|
||||||
|
w.reloadClients(false, []string{"match"}, false)
|
||||||
|
if w.currentAuths != nil && len(w.currentAuths) != 0 {
|
||||||
|
t.Fatalf("expected currentAuths to be nil or empty, got %d", len(w.currentAuths))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSetAuthUpdateQueueNilResetsDispatch(t *testing.T) {
|
func TestSetAuthUpdateQueueNilResetsDispatch(t *testing.T) {
|
||||||
w := &Watcher{}
|
w := &Watcher{}
|
||||||
queue := make(chan AuthUpdate, 1)
|
queue := make(chan AuthUpdate, 1)
|
||||||
@@ -541,6 +582,45 @@ func TestSetAuthUpdateQueueNilResetsDispatch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPersistAsyncEarlyReturns(t *testing.T) {
|
||||||
|
var nilWatcher *Watcher
|
||||||
|
nilWatcher.persistConfigAsync()
|
||||||
|
nilWatcher.persistAuthAsync("msg", "a")
|
||||||
|
|
||||||
|
w := &Watcher{}
|
||||||
|
w.persistConfigAsync()
|
||||||
|
w.persistAuthAsync("msg", " ", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
type errorPersister struct {
|
||||||
|
configCalls int32
|
||||||
|
authCalls int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *errorPersister) PersistConfig(context.Context) error {
|
||||||
|
atomic.AddInt32(&p.configCalls, 1)
|
||||||
|
return fmt.Errorf("persist config error")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *errorPersister) PersistAuthFiles(context.Context, string, ...string) error {
|
||||||
|
atomic.AddInt32(&p.authCalls, 1)
|
||||||
|
return fmt.Errorf("persist auth error")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPersistAsyncErrorPaths(t *testing.T) {
|
||||||
|
p := &errorPersister{}
|
||||||
|
w := &Watcher{storePersister: p}
|
||||||
|
w.persistConfigAsync()
|
||||||
|
w.persistAuthAsync("msg", "a")
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
if atomic.LoadInt32(&p.configCalls) != 1 {
|
||||||
|
t.Fatalf("expected PersistConfig to be called once, got %d", p.configCalls)
|
||||||
|
}
|
||||||
|
if atomic.LoadInt32(&p.authCalls) != 1 {
|
||||||
|
t.Fatalf("expected PersistAuthFiles to be called once, got %d", p.authCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStopConfigReloadTimerSafeWhenNil(t *testing.T) {
|
func TestStopConfigReloadTimerSafeWhenNil(t *testing.T) {
|
||||||
w := &Watcher{}
|
w := &Watcher{}
|
||||||
w.stopConfigReloadTimer()
|
w.stopConfigReloadTimer()
|
||||||
@@ -608,6 +688,803 @@ func TestDispatchAuthUpdatesFlushesQueue(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDispatchLoopExitsOnContextDoneWhileSending(t *testing.T) {
|
||||||
|
queue := make(chan AuthUpdate) // unbuffered to block sends
|
||||||
|
w := &Watcher{
|
||||||
|
authQueue: queue,
|
||||||
|
pendingUpdates: map[string]AuthUpdate{
|
||||||
|
"k": {Action: AuthUpdateActionAdd, ID: "k"},
|
||||||
|
},
|
||||||
|
pendingOrder: []string{"k"},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
w.dispatchLoop(ctx)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("expected dispatchLoop to exit after ctx canceled while blocked on send")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessEventsHandlesEventErrorAndChannelClose(t *testing.T) {
|
||||||
|
w := &Watcher{
|
||||||
|
watcher: &fsnotify.Watcher{
|
||||||
|
Events: make(chan fsnotify.Event, 2),
|
||||||
|
Errors: make(chan error, 2),
|
||||||
|
},
|
||||||
|
configPath: "config.yaml",
|
||||||
|
authDir: "auth",
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
w.processEvents(ctx)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
w.watcher.Events <- fsnotify.Event{Name: "unrelated.txt", Op: fsnotify.Write}
|
||||||
|
w.watcher.Errors <- fmt.Errorf("watcher error")
|
||||||
|
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
close(w.watcher.Events)
|
||||||
|
close(w.watcher.Errors)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
t.Fatal("processEvents did not exit after channels closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessEventsReturnsWhenErrorsChannelClosed(t *testing.T) {
|
||||||
|
w := &Watcher{
|
||||||
|
watcher: &fsnotify.Watcher{
|
||||||
|
Events: nil,
|
||||||
|
Errors: make(chan error),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
close(w.watcher.Errors)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
w.processEvents(ctx)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
t.Fatal("processEvents did not exit after errors channel closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleEventIgnoresUnrelatedFiles(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
authDir := filepath.Join(tmpDir, "auth")
|
||||||
|
if err := os.MkdirAll(authDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create auth dir: %v", err)
|
||||||
|
}
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||||
|
if err := os.WriteFile(configPath, []byte("auth_dir: "+authDir+"\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write config file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var reloads int32
|
||||||
|
w := &Watcher{
|
||||||
|
authDir: authDir,
|
||||||
|
configPath: configPath,
|
||||||
|
lastAuthHashes: make(map[string]string),
|
||||||
|
reloadCallback: func(*config.Config) { atomic.AddInt32(&reloads, 1) },
|
||||||
|
}
|
||||||
|
w.SetConfig(&config.Config{AuthDir: authDir})
|
||||||
|
|
||||||
|
w.handleEvent(fsnotify.Event{Name: filepath.Join(tmpDir, "note.txt"), Op: fsnotify.Write})
|
||||||
|
if atomic.LoadInt32(&reloads) != 0 {
|
||||||
|
t.Fatalf("expected no reloads for unrelated file, got %d", reloads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleEventConfigChangeSchedulesReload(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
authDir := filepath.Join(tmpDir, "auth")
|
||||||
|
if err := os.MkdirAll(authDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create auth dir: %v", err)
|
||||||
|
}
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||||
|
if err := os.WriteFile(configPath, []byte("auth_dir: "+authDir+"\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write config file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var reloads int32
|
||||||
|
w := &Watcher{
|
||||||
|
authDir: authDir,
|
||||||
|
configPath: configPath,
|
||||||
|
lastAuthHashes: make(map[string]string),
|
||||||
|
reloadCallback: func(*config.Config) { atomic.AddInt32(&reloads, 1) },
|
||||||
|
}
|
||||||
|
w.SetConfig(&config.Config{AuthDir: authDir})
|
||||||
|
|
||||||
|
w.handleEvent(fsnotify.Event{Name: configPath, Op: fsnotify.Write})
|
||||||
|
|
||||||
|
time.Sleep(400 * time.Millisecond)
|
||||||
|
if atomic.LoadInt32(&reloads) != 1 {
|
||||||
|
t.Fatalf("expected config change to trigger reload once, got %d", reloads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleEventAuthWriteTriggersUpdate(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
authDir := filepath.Join(tmpDir, "auth")
|
||||||
|
if err := os.MkdirAll(authDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create auth dir: %v", err)
|
||||||
|
}
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||||
|
if err := os.WriteFile(configPath, []byte("auth_dir: "+authDir+"\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write config file: %v", err)
|
||||||
|
}
|
||||||
|
authFile := filepath.Join(authDir, "a.json")
|
||||||
|
if err := os.WriteFile(authFile, []byte(`{"type":"demo"}`), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write auth file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var reloads int32
|
||||||
|
w := &Watcher{
|
||||||
|
authDir: authDir,
|
||||||
|
configPath: configPath,
|
||||||
|
lastAuthHashes: make(map[string]string),
|
||||||
|
reloadCallback: func(*config.Config) { atomic.AddInt32(&reloads, 1) },
|
||||||
|
}
|
||||||
|
w.SetConfig(&config.Config{AuthDir: authDir})
|
||||||
|
|
||||||
|
w.handleEvent(fsnotify.Event{Name: authFile, Op: fsnotify.Write})
|
||||||
|
if atomic.LoadInt32(&reloads) != 1 {
|
||||||
|
t.Fatalf("expected auth write to trigger reload callback, got %d", reloads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleEventRemoveDebounceSkips(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
authDir := filepath.Join(tmpDir, "auth")
|
||||||
|
if err := os.MkdirAll(authDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create auth dir: %v", err)
|
||||||
|
}
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||||
|
if err := os.WriteFile(configPath, []byte("auth_dir: "+authDir+"\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write config file: %v", err)
|
||||||
|
}
|
||||||
|
authFile := filepath.Join(authDir, "remove.json")
|
||||||
|
|
||||||
|
var reloads int32
|
||||||
|
w := &Watcher{
|
||||||
|
authDir: authDir,
|
||||||
|
configPath: configPath,
|
||||||
|
lastAuthHashes: make(map[string]string),
|
||||||
|
lastRemoveTimes: map[string]time.Time{
|
||||||
|
filepath.Clean(authFile): time.Now(),
|
||||||
|
},
|
||||||
|
reloadCallback: func(*config.Config) { atomic.AddInt32(&reloads, 1) },
|
||||||
|
}
|
||||||
|
w.SetConfig(&config.Config{AuthDir: authDir})
|
||||||
|
|
||||||
|
w.handleEvent(fsnotify.Event{Name: authFile, Op: fsnotify.Remove})
|
||||||
|
if atomic.LoadInt32(&reloads) != 0 {
|
||||||
|
t.Fatalf("expected remove to be debounced, got %d", reloads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleEventAtomicReplaceUnchangedSkips(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
authDir := filepath.Join(tmpDir, "auth")
|
||||||
|
if err := os.MkdirAll(authDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create auth dir: %v", err)
|
||||||
|
}
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||||
|
if err := os.WriteFile(configPath, []byte("auth_dir: "+authDir+"\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write config file: %v", err)
|
||||||
|
}
|
||||||
|
authFile := filepath.Join(authDir, "same.json")
|
||||||
|
content := []byte(`{"type":"demo"}`)
|
||||||
|
if err := os.WriteFile(authFile, content, 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write auth file: %v", err)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(content)
|
||||||
|
|
||||||
|
var reloads int32
|
||||||
|
w := &Watcher{
|
||||||
|
authDir: authDir,
|
||||||
|
configPath: configPath,
|
||||||
|
lastAuthHashes: make(map[string]string),
|
||||||
|
reloadCallback: func(*config.Config) { atomic.AddInt32(&reloads, 1) },
|
||||||
|
}
|
||||||
|
w.SetConfig(&config.Config{AuthDir: authDir})
|
||||||
|
w.lastAuthHashes[w.normalizeAuthPath(authFile)] = hexString(sum[:])
|
||||||
|
|
||||||
|
w.handleEvent(fsnotify.Event{Name: authFile, Op: fsnotify.Rename})
|
||||||
|
if atomic.LoadInt32(&reloads) != 0 {
|
||||||
|
t.Fatalf("expected unchanged atomic replace to be skipped, got %d", reloads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleEventAtomicReplaceChangedTriggersUpdate(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
authDir := filepath.Join(tmpDir, "auth")
|
||||||
|
if err := os.MkdirAll(authDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create auth dir: %v", err)
|
||||||
|
}
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||||
|
if err := os.WriteFile(configPath, []byte("auth_dir: "+authDir+"\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write config file: %v", err)
|
||||||
|
}
|
||||||
|
authFile := filepath.Join(authDir, "change.json")
|
||||||
|
oldContent := []byte(`{"type":"demo","v":1}`)
|
||||||
|
newContent := []byte(`{"type":"demo","v":2}`)
|
||||||
|
if err := os.WriteFile(authFile, newContent, 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write auth file: %v", err)
|
||||||
|
}
|
||||||
|
oldSum := sha256.Sum256(oldContent)
|
||||||
|
|
||||||
|
var reloads int32
|
||||||
|
w := &Watcher{
|
||||||
|
authDir: authDir,
|
||||||
|
configPath: configPath,
|
||||||
|
lastAuthHashes: make(map[string]string),
|
||||||
|
reloadCallback: func(*config.Config) { atomic.AddInt32(&reloads, 1) },
|
||||||
|
}
|
||||||
|
w.SetConfig(&config.Config{AuthDir: authDir})
|
||||||
|
w.lastAuthHashes[w.normalizeAuthPath(authFile)] = hexString(oldSum[:])
|
||||||
|
|
||||||
|
w.handleEvent(fsnotify.Event{Name: authFile, Op: fsnotify.Rename})
|
||||||
|
if atomic.LoadInt32(&reloads) != 1 {
|
||||||
|
t.Fatalf("expected changed atomic replace to trigger update, got %d", reloads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleEventRemoveUnknownFileIgnored(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
authDir := filepath.Join(tmpDir, "auth")
|
||||||
|
if err := os.MkdirAll(authDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create auth dir: %v", err)
|
||||||
|
}
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||||
|
if err := os.WriteFile(configPath, []byte("auth_dir: "+authDir+"\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write config file: %v", err)
|
||||||
|
}
|
||||||
|
authFile := filepath.Join(authDir, "unknown.json")
|
||||||
|
|
||||||
|
var reloads int32
|
||||||
|
w := &Watcher{
|
||||||
|
authDir: authDir,
|
||||||
|
configPath: configPath,
|
||||||
|
lastAuthHashes: make(map[string]string),
|
||||||
|
reloadCallback: func(*config.Config) { atomic.AddInt32(&reloads, 1) },
|
||||||
|
}
|
||||||
|
w.SetConfig(&config.Config{AuthDir: authDir})
|
||||||
|
|
||||||
|
w.handleEvent(fsnotify.Event{Name: authFile, Op: fsnotify.Remove})
|
||||||
|
if atomic.LoadInt32(&reloads) != 0 {
|
||||||
|
t.Fatalf("expected unknown remove to be ignored, got %d", reloads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleEventRemoveKnownFileDeletes(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
authDir := filepath.Join(tmpDir, "auth")
|
||||||
|
if err := os.MkdirAll(authDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create auth dir: %v", err)
|
||||||
|
}
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||||
|
if err := os.WriteFile(configPath, []byte("auth_dir: "+authDir+"\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write config file: %v", err)
|
||||||
|
}
|
||||||
|
authFile := filepath.Join(authDir, "known.json")
|
||||||
|
|
||||||
|
var reloads int32
|
||||||
|
w := &Watcher{
|
||||||
|
authDir: authDir,
|
||||||
|
configPath: configPath,
|
||||||
|
lastAuthHashes: make(map[string]string),
|
||||||
|
reloadCallback: func(*config.Config) { atomic.AddInt32(&reloads, 1) },
|
||||||
|
}
|
||||||
|
w.SetConfig(&config.Config{AuthDir: authDir})
|
||||||
|
w.lastAuthHashes[w.normalizeAuthPath(authFile)] = "hash"
|
||||||
|
|
||||||
|
w.handleEvent(fsnotify.Event{Name: authFile, Op: fsnotify.Remove})
|
||||||
|
if atomic.LoadInt32(&reloads) != 1 {
|
||||||
|
t.Fatalf("expected known remove to trigger reload, got %d", reloads)
|
||||||
|
}
|
||||||
|
if _, ok := w.lastAuthHashes[w.normalizeAuthPath(authFile)]; ok {
|
||||||
|
t.Fatal("expected known auth hash to be deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeAuthPathAndDebounceCleanup(t *testing.T) {
|
||||||
|
w := &Watcher{}
|
||||||
|
if got := w.normalizeAuthPath(" "); got != "" {
|
||||||
|
t.Fatalf("expected empty normalize result, got %q", got)
|
||||||
|
}
|
||||||
|
if got := w.normalizeAuthPath(" a/../b "); got != filepath.Clean("a/../b") {
|
||||||
|
t.Fatalf("unexpected normalize result: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.clientsMutex.Lock()
|
||||||
|
w.lastRemoveTimes = make(map[string]time.Time, 140)
|
||||||
|
old := time.Now().Add(-3 * authRemoveDebounceWindow)
|
||||||
|
for i := 0; i < 129; i++ {
|
||||||
|
w.lastRemoveTimes[fmt.Sprintf("old-%d", i)] = old
|
||||||
|
}
|
||||||
|
w.clientsMutex.Unlock()
|
||||||
|
|
||||||
|
w.shouldDebounceRemove("new-path", time.Now())
|
||||||
|
|
||||||
|
w.clientsMutex.Lock()
|
||||||
|
gotLen := len(w.lastRemoveTimes)
|
||||||
|
w.clientsMutex.Unlock()
|
||||||
|
if gotLen >= 129 {
|
||||||
|
t.Fatalf("expected debounce cleanup to shrink map, got %d", gotLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshAuthStateDispatchesRuntimeAuths(t *testing.T) {
|
||||||
|
queue := make(chan AuthUpdate, 8)
|
||||||
|
w := &Watcher{
|
||||||
|
authDir: t.TempDir(),
|
||||||
|
lastAuthHashes: make(map[string]string),
|
||||||
|
}
|
||||||
|
w.SetConfig(&config.Config{AuthDir: w.authDir})
|
||||||
|
w.SetAuthUpdateQueue(queue)
|
||||||
|
defer w.stopDispatch()
|
||||||
|
|
||||||
|
w.clientsMutex.Lock()
|
||||||
|
w.runtimeAuths = map[string]*coreauth.Auth{
|
||||||
|
"nil": nil,
|
||||||
|
"r1": {ID: "r1", Provider: "runtime"},
|
||||||
|
}
|
||||||
|
w.clientsMutex.Unlock()
|
||||||
|
|
||||||
|
w.refreshAuthState(false)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case u := <-queue:
|
||||||
|
if u.Action != AuthUpdateActionAdd || u.ID != "r1" {
|
||||||
|
t.Fatalf("unexpected auth update: %+v", u)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for runtime auth update")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddOrUpdateClientEdgeCases(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
authDir := tmpDir
|
||||||
|
authFile := filepath.Join(tmpDir, "edge.json")
|
||||||
|
if err := os.WriteFile(authFile, []byte(`{"type":"demo"}`), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write auth file: %v", err)
|
||||||
|
}
|
||||||
|
emptyFile := filepath.Join(tmpDir, "empty.json")
|
||||||
|
if err := os.WriteFile(emptyFile, []byte(""), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write empty auth file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var reloads int32
|
||||||
|
w := &Watcher{
|
||||||
|
authDir: authDir,
|
||||||
|
lastAuthHashes: make(map[string]string),
|
||||||
|
reloadCallback: func(*config.Config) { atomic.AddInt32(&reloads, 1) },
|
||||||
|
}
|
||||||
|
|
||||||
|
w.addOrUpdateClient(filepath.Join(tmpDir, "missing.json"))
|
||||||
|
w.addOrUpdateClient(emptyFile)
|
||||||
|
if atomic.LoadInt32(&reloads) != 0 {
|
||||||
|
t.Fatalf("expected no reloads for missing/empty file, got %d", reloads)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.addOrUpdateClient(authFile) // config nil -> should not panic or update
|
||||||
|
if len(w.lastAuthHashes) != 0 {
|
||||||
|
t.Fatalf("expected no hash entries without config, got %d", len(w.lastAuthHashes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadFileClientsWalkError(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
noAccessDir := filepath.Join(tmpDir, "0noaccess")
|
||||||
|
if err := os.MkdirAll(noAccessDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create noaccess dir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(noAccessDir, 0); err != nil {
|
||||||
|
t.Skipf("chmod not supported: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chmod(noAccessDir, 0o755) }()
|
||||||
|
|
||||||
|
cfg := &config.Config{AuthDir: tmpDir}
|
||||||
|
w := &Watcher{}
|
||||||
|
w.SetConfig(cfg)
|
||||||
|
|
||||||
|
count := w.loadFileClients(cfg)
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("expected count 0 due to walk error, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReloadConfigIfChangedHandlesMissingAndEmpty(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
authDir := filepath.Join(tmpDir, "auth")
|
||||||
|
if err := os.MkdirAll(authDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create auth dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := &Watcher{
|
||||||
|
configPath: filepath.Join(tmpDir, "missing.yaml"),
|
||||||
|
authDir: authDir,
|
||||||
|
}
|
||||||
|
w.reloadConfigIfChanged() // missing file -> log + return
|
||||||
|
|
||||||
|
emptyPath := filepath.Join(tmpDir, "empty.yaml")
|
||||||
|
if err := os.WriteFile(emptyPath, []byte(""), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write empty config: %v", err)
|
||||||
|
}
|
||||||
|
w.configPath = emptyPath
|
||||||
|
w.reloadConfigIfChanged() // empty file -> early return
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReloadConfigUsesMirroredAuthDir(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
authDir := filepath.Join(tmpDir, "auth")
|
||||||
|
if err := os.MkdirAll(authDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create auth dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||||
|
if err := os.WriteFile(configPath, []byte("auth_dir: "+filepath.Join(tmpDir, "other")+"\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := &Watcher{
|
||||||
|
configPath: configPath,
|
||||||
|
authDir: authDir,
|
||||||
|
mirroredAuthDir: authDir,
|
||||||
|
lastAuthHashes: make(map[string]string),
|
||||||
|
}
|
||||||
|
w.SetConfig(&config.Config{AuthDir: authDir})
|
||||||
|
|
||||||
|
if ok := w.reloadConfig(); !ok {
|
||||||
|
t.Fatal("expected reloadConfig to succeed")
|
||||||
|
}
|
||||||
|
|
||||||
|
w.clientsMutex.RLock()
|
||||||
|
defer w.clientsMutex.RUnlock()
|
||||||
|
if w.config == nil || w.config.AuthDir != authDir {
|
||||||
|
t.Fatalf("expected AuthDir to be overridden by mirroredAuthDir %s, got %+v", authDir, w.config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReloadConfigFiltersAffectedOAuthProviders(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
authDir := filepath.Join(tmpDir, "auth")
|
||||||
|
if err := os.MkdirAll(authDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create auth dir: %v", err)
|
||||||
|
}
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||||
|
|
||||||
|
// Ensure SnapshotCoreAuths yields a provider that is NOT affected, so we can assert it survives.
|
||||||
|
if err := os.WriteFile(filepath.Join(authDir, "provider-b.json"), []byte(`{"type":"provider-b","email":"b@example.com"}`), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write auth file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldCfg := &config.Config{
|
||||||
|
AuthDir: authDir,
|
||||||
|
OAuthExcludedModels: map[string][]string{
|
||||||
|
"provider-a": {"m1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
newCfg := &config.Config{
|
||||||
|
AuthDir: authDir,
|
||||||
|
OAuthExcludedModels: map[string][]string{
|
||||||
|
"provider-a": {"m2"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data, err := yaml.Marshal(newCfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to marshal config: %v", err)
|
||||||
|
}
|
||||||
|
if err = os.WriteFile(configPath, data, 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := &Watcher{
|
||||||
|
configPath: configPath,
|
||||||
|
authDir: authDir,
|
||||||
|
lastAuthHashes: make(map[string]string),
|
||||||
|
currentAuths: map[string]*coreauth.Auth{
|
||||||
|
"a": {ID: "a", Provider: "provider-a"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.SetConfig(oldCfg)
|
||||||
|
|
||||||
|
if ok := w.reloadConfig(); !ok {
|
||||||
|
t.Fatal("expected reloadConfig to succeed")
|
||||||
|
}
|
||||||
|
|
||||||
|
w.clientsMutex.RLock()
|
||||||
|
defer w.clientsMutex.RUnlock()
|
||||||
|
for _, auth := range w.currentAuths {
|
||||||
|
if auth != nil && auth.Provider == "provider-a" {
|
||||||
|
t.Fatal("expected affected provider auth to be filtered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foundB := false
|
||||||
|
for _, auth := range w.currentAuths {
|
||||||
|
if auth != nil && auth.Provider == "provider-b" {
|
||||||
|
foundB = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundB {
|
||||||
|
t.Fatal("expected unaffected provider auth to remain")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartFailsWhenAuthDirMissing(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||||
|
if err := os.WriteFile(configPath, []byte("auth_dir: "+filepath.Join(tmpDir, "missing-auth")+"\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write config file: %v", err)
|
||||||
|
}
|
||||||
|
authDir := filepath.Join(tmpDir, "missing-auth")
|
||||||
|
|
||||||
|
w, err := NewWatcher(configPath, authDir, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create watcher: %v", err)
|
||||||
|
}
|
||||||
|
defer w.Stop()
|
||||||
|
w.SetConfig(&config.Config{AuthDir: authDir})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := w.Start(ctx); err == nil {
|
||||||
|
t.Fatal("expected Start to fail for missing auth dir")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatchRuntimeAuthUpdateReturnsFalseWithoutQueue(t *testing.T) {
|
||||||
|
w := &Watcher{}
|
||||||
|
if ok := w.DispatchRuntimeAuthUpdate(AuthUpdate{Action: AuthUpdateActionAdd, Auth: &coreauth.Auth{ID: "a"}}); ok {
|
||||||
|
t.Fatal("expected DispatchRuntimeAuthUpdate to return false when no queue configured")
|
||||||
|
}
|
||||||
|
if ok := w.DispatchRuntimeAuthUpdate(AuthUpdate{Action: AuthUpdateActionDelete, Auth: &coreauth.Auth{ID: "a"}}); ok {
|
||||||
|
t.Fatal("expected DispatchRuntimeAuthUpdate delete to return false when no queue configured")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeAuthNil(t *testing.T) {
|
||||||
|
if normalizeAuth(nil) != nil {
|
||||||
|
t.Fatal("expected normalizeAuth(nil) to return nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubStore implements coreauth.Store plus watcher-specific persistence helpers.
|
||||||
|
type stubStore struct {
|
||||||
|
authDir string
|
||||||
|
cfgPersisted int32
|
||||||
|
authPersisted int32
|
||||||
|
lastAuthMessage string
|
||||||
|
lastAuthPaths []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubStore) List(context.Context) ([]*coreauth.Auth, error) { return nil, nil }
|
||||||
|
func (s *stubStore) Save(context.Context, *coreauth.Auth) (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
func (s *stubStore) Delete(context.Context, string) error { return nil }
|
||||||
|
func (s *stubStore) PersistConfig(context.Context) error {
|
||||||
|
atomic.AddInt32(&s.cfgPersisted, 1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *stubStore) PersistAuthFiles(_ context.Context, message string, paths ...string) error {
|
||||||
|
atomic.AddInt32(&s.authPersisted, 1)
|
||||||
|
s.lastAuthMessage = message
|
||||||
|
s.lastAuthPaths = paths
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *stubStore) AuthDir() string { return s.authDir }
|
||||||
|
|
||||||
|
func TestNewWatcherDetectsPersisterAndAuthDir(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
store := &stubStore{authDir: tmp}
|
||||||
|
orig := sdkAuth.GetTokenStore()
|
||||||
|
sdkAuth.RegisterTokenStore(store)
|
||||||
|
defer sdkAuth.RegisterTokenStore(orig)
|
||||||
|
|
||||||
|
w, err := NewWatcher("config.yaml", "auth", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewWatcher failed: %v", err)
|
||||||
|
}
|
||||||
|
if w.storePersister == nil {
|
||||||
|
t.Fatal("expected storePersister to be set from token store")
|
||||||
|
}
|
||||||
|
if w.mirroredAuthDir != tmp {
|
||||||
|
t.Fatalf("expected mirroredAuthDir %s, got %s", tmp, w.mirroredAuthDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPersistConfigAndAuthAsyncInvokePersister(t *testing.T) {
|
||||||
|
w := &Watcher{
|
||||||
|
storePersister: &stubStore{},
|
||||||
|
}
|
||||||
|
|
||||||
|
w.persistConfigAsync()
|
||||||
|
w.persistAuthAsync("msg", " a ", "", "b ")
|
||||||
|
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
store := w.storePersister.(*stubStore)
|
||||||
|
if atomic.LoadInt32(&store.cfgPersisted) != 1 {
|
||||||
|
t.Fatalf("expected PersistConfig to be called once, got %d", store.cfgPersisted)
|
||||||
|
}
|
||||||
|
if atomic.LoadInt32(&store.authPersisted) != 1 {
|
||||||
|
t.Fatalf("expected PersistAuthFiles to be called once, got %d", store.authPersisted)
|
||||||
|
}
|
||||||
|
if store.lastAuthMessage != "msg" {
|
||||||
|
t.Fatalf("unexpected auth message: %s", store.lastAuthMessage)
|
||||||
|
}
|
||||||
|
if len(store.lastAuthPaths) != 2 || store.lastAuthPaths[0] != "a" || store.lastAuthPaths[1] != "b" {
|
||||||
|
t.Fatalf("unexpected filtered paths: %#v", store.lastAuthPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduleConfigReloadDebounces(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
authDir := tmp
|
||||||
|
cfgPath := tmp + "/config.yaml"
|
||||||
|
if err := os.WriteFile(cfgPath, []byte("auth_dir: "+authDir+"\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var reloads int32
|
||||||
|
w := &Watcher{
|
||||||
|
configPath: cfgPath,
|
||||||
|
authDir: authDir,
|
||||||
|
reloadCallback: func(*config.Config) { atomic.AddInt32(&reloads, 1) },
|
||||||
|
}
|
||||||
|
w.SetConfig(&config.Config{AuthDir: authDir})
|
||||||
|
|
||||||
|
w.scheduleConfigReload()
|
||||||
|
w.scheduleConfigReload()
|
||||||
|
|
||||||
|
time.Sleep(400 * time.Millisecond)
|
||||||
|
|
||||||
|
if atomic.LoadInt32(&reloads) != 1 {
|
||||||
|
t.Fatalf("expected single debounced reload, got %d", reloads)
|
||||||
|
}
|
||||||
|
if w.lastConfigHash == "" {
|
||||||
|
t.Fatal("expected lastConfigHash to be set after reload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareAuthUpdatesLockedForceAndDelete(t *testing.T) {
|
||||||
|
w := &Watcher{
|
||||||
|
currentAuths: map[string]*coreauth.Auth{
|
||||||
|
"a": {ID: "a", Provider: "p1"},
|
||||||
|
},
|
||||||
|
authQueue: make(chan AuthUpdate, 4),
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := w.prepareAuthUpdatesLocked([]*coreauth.Auth{{ID: "a", Provider: "p2"}}, false)
|
||||||
|
if len(updates) != 1 || updates[0].Action != AuthUpdateActionModify || updates[0].ID != "a" {
|
||||||
|
t.Fatalf("unexpected modify updates: %+v", updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
updates = w.prepareAuthUpdatesLocked([]*coreauth.Auth{{ID: "a", Provider: "p2"}}, true)
|
||||||
|
if len(updates) != 1 || updates[0].Action != AuthUpdateActionModify {
|
||||||
|
t.Fatalf("expected force modify, got %+v", updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
updates = w.prepareAuthUpdatesLocked([]*coreauth.Auth{}, false)
|
||||||
|
if len(updates) != 1 || updates[0].Action != AuthUpdateActionDelete || updates[0].ID != "a" {
|
||||||
|
t.Fatalf("expected delete for missing auth, got %+v", updates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthEqualIgnoresTemporalFields(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
a := &coreauth.Auth{ID: "x", CreatedAt: now}
|
||||||
|
b := &coreauth.Auth{ID: "x", CreatedAt: now.Add(5 * time.Second)}
|
||||||
|
if !authEqual(a, b) {
|
||||||
|
t.Fatal("expected authEqual to ignore temporal differences")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatchLoopExitsWhenQueueNilAndContextCanceled(t *testing.T) {
|
||||||
|
w := &Watcher{
|
||||||
|
dispatchCond: nil,
|
||||||
|
pendingUpdates: map[string]AuthUpdate{"k": {ID: "k"}},
|
||||||
|
pendingOrder: []string{"k"},
|
||||||
|
}
|
||||||
|
w.dispatchCond = sync.NewCond(&w.dispatchMu)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
w.dispatchLoop(ctx)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
w.dispatchMu.Lock()
|
||||||
|
w.dispatchCond.Broadcast()
|
||||||
|
w.dispatchMu.Unlock()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
t.Fatal("dispatchLoop did not exit after context cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReloadClientsFiltersOAuthProvidersWithoutRescan(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
w := &Watcher{
|
||||||
|
authDir: tmp,
|
||||||
|
config: &config.Config{AuthDir: tmp},
|
||||||
|
currentAuths: map[string]*coreauth.Auth{
|
||||||
|
"a": {ID: "a", Provider: "Match"},
|
||||||
|
"b": {ID: "b", Provider: "other"},
|
||||||
|
},
|
||||||
|
lastAuthHashes: map[string]string{"cached": "hash"},
|
||||||
|
}
|
||||||
|
|
||||||
|
w.reloadClients(false, []string{"match"}, false)
|
||||||
|
|
||||||
|
w.clientsMutex.RLock()
|
||||||
|
defer w.clientsMutex.RUnlock()
|
||||||
|
if _, ok := w.currentAuths["a"]; ok {
|
||||||
|
t.Fatal("expected filtered provider to be removed")
|
||||||
|
}
|
||||||
|
if len(w.lastAuthHashes) != 1 {
|
||||||
|
t.Fatalf("expected existing hash cache to be retained, got %d", len(w.lastAuthHashes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduleProcessEventsStopsOnContextDone(t *testing.T) {
|
||||||
|
w := &Watcher{
|
||||||
|
watcher: &fsnotify.Watcher{
|
||||||
|
Events: make(chan fsnotify.Event, 1),
|
||||||
|
Errors: make(chan error, 1),
|
||||||
|
},
|
||||||
|
configPath: "config.yaml",
|
||||||
|
authDir: "auth",
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
w.processEvents(ctx)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
t.Fatal("processEvents did not exit on context cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func hexString(data []byte) string {
|
func hexString(data []byte) string {
|
||||||
return strings.ToLower(fmt.Sprintf("%x", data))
|
return strings.ToLower(fmt.Sprintf("%x", data))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user