diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index 770b5242..29113f59 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "strings" + "sync" "sync/atomic" "testing" "time" @@ -16,6 +17,7 @@ import ( "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/synthesizer" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" "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) { tmpDir := t.TempDir() authFile := filepath.Join(tmpDir, "one.json") @@ -528,6 +552,23 @@ func TestReloadClientsLogsConfigDiffs(t *testing.T) { 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) { w := &Watcher{} 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) { w := &Watcher{} 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 { return strings.ToLower(fmt.Sprintf("%x", data)) }