package config import ( "os" "path/filepath" "sync/atomic" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewConfigWatcher_DirectoryPriority(t *testing.T) { tempDir := t.TempDir() dir1 := filepath.Join(tempDir, "dir1") dir2 := filepath.Join(tempDir, "dir2") require.NoError(t, os.MkdirAll(dir1, 0755)) require.NoError(t, os.MkdirAll(dir2, 0755)) require.NoError(t, os.WriteFile(filepath.Join(dir1, ".env"), []byte(""), 0644)) require.NoError(t, os.WriteFile(filepath.Join(dir2, ".env"), []byte(""), 0644)) tests := []struct { name string cfg *Config authConfigDir string dataDir string expectedPath string }{ { name: "Prefer PULSE_AUTH_CONFIG_DIR", cfg: &Config{ConfigPath: dir2}, authConfigDir: dir1, dataDir: dir2, expectedPath: filepath.Join(dir1, ".env"), }, { name: "Prefer ConfigPath over PULSE_DATA_DIR", cfg: &Config{ConfigPath: dir1}, dataDir: dir2, expectedPath: filepath.Join(dir1, ".env"), }, { name: "Prefer DataPath when ConfigPath empty", cfg: &Config{DataPath: dir1}, dataDir: dir2, expectedPath: filepath.Join(dir1, ".env"), }, { name: "Fallback to PULSE_DATA_DIR", cfg: &Config{}, dataDir: dir2, expectedPath: filepath.Join(dir2, ".env"), }, { name: "Default fallback uses defaultDataDir", cfg: &Config{}, expectedPath: filepath.Join(defaultDataDir, ".env"), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.authConfigDir != "" { t.Setenv("PULSE_AUTH_CONFIG_DIR", tt.authConfigDir) } else { os.Unsetenv("PULSE_AUTH_CONFIG_DIR") } if tt.dataDir != "" { t.Setenv("PULSE_DATA_DIR", tt.dataDir) } else { os.Unsetenv("PULSE_DATA_DIR") } cw, err := NewConfigWatcher(tt.cfg) require.NoError(t, err) defer cw.Stop() assert.Equal(t, tt.expectedPath, cw.envPath) }) } } func TestConfigWatcher_ReloadConfig(t *testing.T) { // Setup tempDir := t.TempDir() envPath := filepath.Join(tempDir, ".env") // Ensure temp dir is used t.Setenv("PULSE_AUTH_CONFIG_DIR", tempDir) cfg := &Config{} cw, err := NewConfigWatcher(cfg) require.NoError(t, err) // Create .env content envContent := `PULSE_AUTH_USER="admin" PULSE_AUTH_PASS="secret"` require.NoError(t, os.WriteFile(envPath, []byte(envContent), 0644)) // Reload cw.reloadConfig() // Assert assert.Equal(t, "admin", cfg.AuthUser) assert.Equal(t, "secret", cfg.AuthPass) // Test update envContentUpdated := `PULSE_AUTH_USER="newadmin" PULSE_AUTH_PASS="newsecret"` require.NoError(t, os.WriteFile(envPath, []byte(envContentUpdated), 0644)) cw.reloadConfig() assert.Equal(t, "newadmin", cfg.AuthUser) assert.Equal(t, "newsecret", cfg.AuthPass) } func TestConfigWatcher_ReloadConfig_MockSettings(t *testing.T) { tempDir := t.TempDir() envPath := filepath.Join(tempDir, ".env") t.Setenv("PULSE_AUTH_CONFIG_DIR", tempDir) cfg := &Config{} cw, err := NewConfigWatcher(cfg) require.NoError(t, err) var callbackCalled atomic.Bool cw.SetMockReloadCallback(func() { callbackCalled.Store(true) }) require.NoError(t, os.WriteFile(envPath, []byte(`PULSE_MOCK_TEST="true"`), 0644)) cw.reloadConfig() assert.Equal(t, "true", os.Getenv("PULSE_MOCK_TEST")) require.Eventually(t, func() bool { return callbackCalled.Load() }, 1*time.Second, 10*time.Millisecond) os.Unsetenv("PULSE_MOCK_TEST") } func TestConfigWatcher_ReloadAPITokens(t *testing.T) { // Setup persistence tempDir := t.TempDir() p := NewConfigPersistence(tempDir) // Setup Watcher apiTokensPath := filepath.Join(tempDir, "api_tokens.json") cfg := &Config{} cw := &ConfigWatcher{ config: cfg, apiTokensPath: apiTokensPath, persistence: p, } var callbackCalled atomic.Bool cw.SetAPITokenReloadCallback(func() { callbackCalled.Store(true) }) // Create API tokens file via persistence to ensure format matches tokens := []APITokenRecord{ { ID: "123", Name: "Test Token", Hash: "hash123", Prefix: "pulse_", Suffix: "123", Scopes: []string{"read"}, CreatedAt: time.Now(), }, } require.NoError(t, p.SaveAPITokens(tokens)) // Reload cw.reloadAPITokens() // Assert Mu.Lock() // config fields might be accessed under lock in real usage, but here we just read assert.Len(t, cfg.APITokens, 1) if len(cfg.APITokens) > 0 { assert.Equal(t, "Test Token", cfg.APITokens[0].Name) assert.Equal(t, "default", cfg.APITokens[0].OrgID) } Mu.Unlock() persisted, err := p.LoadAPITokens() require.NoError(t, err) require.Len(t, persisted, 1) assert.Equal(t, "default", persisted[0].OrgID) // Wait for callback require.Eventually(t, func() bool { return callbackCalled.Load() }, 1*time.Second, 10*time.Millisecond) } func TestConfigWatcher_CalculateFileHash_Error(t *testing.T) { cw := &ConfigWatcher{} _, err := cw.calculateFileHash("/non/existent/file") assert.Error(t, err) } func TestConfigWatcher_StartStop(t *testing.T) { tempDir := t.TempDir() envPath := filepath.Join(tempDir, ".env") require.NoError(t, os.WriteFile(envPath, []byte(""), 0644)) t.Setenv("PULSE_AUTH_CONFIG_DIR", tempDir) cfg := &Config{} cw, err := NewConfigWatcher(cfg) require.NoError(t, err) // Start err = cw.Start() require.NoError(t, err) // Stop cw.Stop() // Verify stop channel closed select { case <-cw.stopChan: // Closed default: t.Error("Stop channel should be closed") } } func TestConfigWatcher_PollForChanges(t *testing.T) { // Setup tempDir := t.TempDir() envPath := filepath.Join(tempDir, ".env") apiTokensPath := filepath.Join(tempDir, "api_tokens.json") // Create initial files require.NoError(t, os.WriteFile(envPath, []byte("PULSE_AUTH_USER=\"initial\"\nPULSE_MOCK_TEST=\"1\""), 0644)) require.NoError(t, os.WriteFile(apiTokensPath, []byte("[]"), 0644)) // Ensure temp dir is used t.Setenv("PULSE_AUTH_CONFIG_DIR", tempDir) cfg := &Config{} cw, err := NewConfigWatcher(cfg) require.NoError(t, err) if stat, err := os.Stat(apiTokensPath); err == nil { cw.apiTokensLastModTime = stat.ModTime() } // Set short poll interval cw.pollInterval = 10 * time.Millisecond // Hook up callbacks var mockCalled atomic.Bool var tokenCalled atomic.Bool cw.SetMockReloadCallback(func() { mockCalled.Store(true) }) cw.SetAPITokenReloadCallback(func() { tokenCalled.Store(true) }) require.NotNil(t, cw.persistence) // Run pollForChanges in background go cw.pollForChanges() defer cw.Stop() // 1. Update .env require.NoError(t, os.WriteFile(envPath, []byte(`PULSE_AUTH_USER="updated"`), 0644)) // Avoid relying on filesystem timestamp granularity. future := time.Now().Add(2 * time.Second) require.NoError(t, os.Chtimes(envPath, future, future)) require.Eventually(t, func() bool { Mu.RLock() defer Mu.RUnlock() return cfg.AuthUser == "updated" }, 1*time.Second, 10*time.Millisecond) // 2. Update .env mock settings require.NoError(t, os.WriteFile(envPath, []byte("PULSE_AUTH_USER=\"updated\"\nPULSE_MOCK_TEST=\"2\""), 0644)) future = future.Add(2 * time.Second) require.NoError(t, os.Chtimes(envPath, future, future)) require.Eventually(t, func() bool { return mockCalled.Load() }, 1*time.Second, 10*time.Millisecond) assert.Equal(t, "2", os.Getenv("PULSE_MOCK_TEST")) // 3. Update api_tokens.json // Write valid JSON // Write to the watcher-owned persistence so reload reads the same installation scope. tokens := []APITokenRecord{{ID: "new", Hash: "hash", Name: "New"}} require.NoError(t, cw.persistence.SaveAPITokens(tokens)) future = future.Add(2 * time.Second) require.NoError(t, os.Chtimes(apiTokensPath, future, future)) // Waiting for polling to pick up change in file modification // persistence.SaveAPITokens writes to the file. require.Eventually(t, func() bool { return tokenCalled.Load() }, 1*time.Second, 10*time.Millisecond) Mu.RLock() defer Mu.RUnlock() assert.Len(t, cfg.APITokens, 1) } func TestConfigWatcher_ReloadConfig_APITokensIgnored(t *testing.T) { tempDir := t.TempDir() envPath := filepath.Join(tempDir, ".env") // Ensure temp dir is used t.Setenv("PULSE_AUTH_CONFIG_DIR", tempDir) cfg := &Config{ APITokens: []APITokenRecord{{ID: "id", Hash: "hash"}}, } cw, err := NewConfigWatcher(cfg) require.NoError(t, err) // API_TOKEN/API_TOKENS are ignored by watcher reloads in strict v6 mode. envContent := `API_TOKEN="token1" API_TOKENS="token2,token3"` require.NoError(t, os.WriteFile(envPath, []byte(envContent), 0644)) cw.reloadConfig() assert.Len(t, cfg.APITokens, 1) assert.Equal(t, "hash", cfg.APITokens[0].Hash) } func TestConfigWatcher_ReloadConfig_Auth(t *testing.T) { tempDir := t.TempDir() envPath := filepath.Join(tempDir, ".env") t.Setenv("PULSE_AUTH_CONFIG_DIR", tempDir) cfg := &Config{ AuthUser: "oldUser", AuthPass: "oldPass", } cw, err := NewConfigWatcher(cfg) require.NoError(t, err) // Update auth envContent := `PULSE_AUTH_USER="newUser" PULSE_AUTH_PASS="newPass"` require.NoError(t, os.WriteFile(envPath, []byte(envContent), 0644)) cw.reloadConfig() assert.Equal(t, "newUser", cfg.AuthUser) assert.Equal(t, "newPass", cfg.AuthPass) // Remove auth require.NoError(t, os.WriteFile(envPath, []byte(""), 0644)) cw.reloadConfig() assert.Equal(t, "", cfg.AuthUser) assert.Equal(t, "", cfg.AuthPass) } func TestConfigWatcher_ReloadConfig_Manual(t *testing.T) { tempDir := t.TempDir() envPath := filepath.Join(tempDir, ".env") t.Setenv("PULSE_AUTH_CONFIG_DIR", tempDir) require.NoError(t, os.WriteFile(envPath, []byte(`PULSE_AUTH_USER="initial"`), 0644)) cfg := &Config{} cw, err := NewConfigWatcher(cfg) require.NoError(t, err) // Update file require.NoError(t, os.WriteFile(envPath, []byte(`PULSE_AUTH_USER="manual"`), 0644)) // Manual Trigger cw.ReloadConfig() assert.Equal(t, "manual", cfg.AuthUser) } func TestConfigWatcher_ReloadConfig_RemovesMockSetting(t *testing.T) { tempDir := t.TempDir() envPath := filepath.Join(tempDir, ".env") t.Setenv("PULSE_AUTH_CONFIG_DIR", tempDir) cfg := &Config{} cw, err := NewConfigWatcher(cfg) require.NoError(t, err) require.NoError(t, os.WriteFile(envPath, []byte(`PULSE_MOCK_TEST="base"`), 0644)) cw.reloadConfig() require.Equal(t, "base", os.Getenv("PULSE_MOCK_TEST")) require.NoError(t, os.WriteFile(envPath, []byte(""), 0644)) cw.reloadConfig() assert.Equal(t, "", os.Getenv("PULSE_MOCK_TEST")) os.Unsetenv("PULSE_MOCK_TEST") } func TestConfigWatcher_ReloadConfig_MissingEnvRemovesMockSetting(t *testing.T) { tempDir := t.TempDir() t.Setenv("PULSE_AUTH_CONFIG_DIR", tempDir) os.Setenv("PULSE_MOCK_TEST", "stale") t.Cleanup(func() { os.Unsetenv("PULSE_MOCK_TEST") }) cw, err := NewConfigWatcher(&Config{}) require.NoError(t, err) cw.reloadConfig() assert.Equal(t, "", os.Getenv("PULSE_MOCK_TEST")) } func TestConfigWatcher_ReloadAPITokens_Retries(t *testing.T) { tempDir := t.TempDir() p := NewConfigPersistence(tempDir) apiTokensPath := filepath.Join(tempDir, "api_tokens.json") require.NoError(t, os.WriteFile(apiTokensPath, []byte("{invalid-json"), 0644)) cfg := &Config{} cw := &ConfigWatcher{ config: cfg, apiTokensPath: apiTokensPath, persistence: p, } // Should attempt retries and log errors but continue cw.reloadAPITokens() }