package config import ( "os" "path/filepath" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewConfigWatcher_DirectoryPriority(t *testing.T) { // Create temporary directories to simulate different environments tempDir := t.TempDir() dir1 := filepath.Join(tempDir, "dir1") // Explicit auth dir dir2 := filepath.Join(tempDir, "dir2") // DATA_DIR require.NoError(t, os.MkdirAll(dir1, 0755)) require.NoError(t, os.MkdirAll(dir2, 0755)) // Create .env files 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 authConfigDir string dataDir string expectedPrefix string }{ // Test case "Fallback to PULSE_DATA_DIR" removed as it depends on /etc/pulse/.env non-existence { name: "Prefer PULSE_AUTH_CONFIG_DIR", authConfigDir: dir1, dataDir: dir2, expectedPrefix: dir1, }, { name: "Default fallback (when dir2 is not treated as production)", authConfigDir: "", dataDir: "", expectedPrefix: "/etc/pulse", // Default fallback }, } 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") } cfg := &Config{} cw, err := NewConfigWatcher(cfg) require.NoError(t, err) // Check if envPath starts with the expected directory // Note: NewConfigWatcher logic has specific checks for /etc/pulse and /data // For arbitrary temp dirs, it might fall back to option 5 or 6 depending on checks. // Let's verify what it actually picked. if tt.expectedPrefix != "/etc/pulse" && !strings.HasPrefix(cw.envPath, tt.expectedPrefix) { // If we expected a specific temp dir but got something else, verify why. // In "Prefer PULSE_AUTH_CONFIG_DIR", it should pick dir1. // In "Fallback to PULSE_DATA_DIR", it should pick dir2 (Option 5). t.Errorf("Expected envPath to start with %s, got %s", tt.expectedPrefix, 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_ReloadMockConfig(t *testing.T) { // Setup tempDir := t.TempDir() // We need to manipulate where NewConfigWatcher looks for mock.env. // It looks in /opt/pulse by default if not docker. // Since we can't easily change the hardcoded path in NewConfigWatcher without refactoring, // we will manually set cw.mockEnvPath and create the file there. mockEnvPath := filepath.Join(tempDir, "mock.env") cfg := &Config{} cw := &ConfigWatcher{ config: cfg, mockEnvPath: mockEnvPath, } // Hook callbackCalled := false cw.SetMockReloadCallback(func() { callbackCalled = true }) // Create mock.env envContent := `PULSE_MOCK_TEST="true"` require.NoError(t, os.WriteFile(mockEnvPath, []byte(envContent), 0644)) // Reload cw.reloadMockConfig() // Validation val := os.Getenv("PULSE_MOCK_TEST") assert.Equal(t, "true", val) // Wait for callback (it's called in a goroutine) require.Eventually(t, func() bool { return callbackCalled }, 1*time.Second, 10*time.Millisecond) // Cleanup os.Unsetenv("PULSE_MOCK_TEST") } func TestConfigWatcher_ReloadAPITokens(t *testing.T) { // Setup persistence tempDir := t.TempDir() p := NewConfigPersistence(tempDir) // Save globalPersistence to restore later originalPersistence := globalPersistence globalPersistence = p defer func() { globalPersistence = originalPersistence }() // Setup Watcher apiTokensPath := filepath.Join(tempDir, "api_tokens.json") cfg := &Config{} cw := &ConfigWatcher{ config: cfg, apiTokensPath: apiTokensPath, } callbackCalled := false cw.SetAPITokenReloadCallback(func() { callbackCalled = 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) } Mu.Unlock() // Wait for callback require.Eventually(t, func() bool { return callbackCalled }, 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") mockEnvPath := filepath.Join(tempDir, "mock.env") apiTokensPath := filepath.Join(tempDir, "api_tokens.json") // Create initial files require.NoError(t, os.WriteFile(envPath, []byte(`PULSE_AUTH_USER="initial"`), 0644)) // We need mock.env to exist locally to have NewConfigWatcher pick it up, // BUT NewConfigWatcher uses hardcoded /opt/pulse for mock logic unless we trick it or it's changed. // Actually NewConfigWatcher checks /opt/pulse/mock.env if NOT docker. // We can't easily change the path it looks for. // However, `pollForChanges` uses `cw.mockEnvPath`. // We can manually set `cw.mockEnvPath` in the test structure as we did in TestConfigWatcher_ReloadMockConfig. 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) // Manually set mockEnvPath for test visibility since default is /opt/pulse cw.mockEnvPath = mockEnvPath require.NoError(t, os.WriteFile(mockEnvPath, []byte(`PULSE_MOCK_TEST="1"`), 0644)) // Set initial mod times (simulate what Start() or NewConfigWatcher would do) if stat, err := os.Stat(mockEnvPath); err == nil { cw.mockLastModTime = stat.ModTime() } if stat, err := os.Stat(apiTokensPath); err == nil { cw.apiTokensLastModTime = stat.ModTime() } // Set short poll interval cw.pollInterval = 10 * time.Millisecond // Hook up callbacks mockCalled := false tokenCalled := false cw.SetMockReloadCallback(func() { mockCalled = true }) cw.SetAPITokenReloadCallback(func() { tokenCalled = true }) // Mock global persistence for API token reloads p := NewConfigPersistence(tempDir) originalPersistence := globalPersistence globalPersistence = p defer func() { globalPersistence = originalPersistence }() // Run pollForChanges in background go cw.pollForChanges() defer cw.Stop() // Wait a bit time.Sleep(20 * time.Millisecond) // 1. Update .env time.Sleep(100 * time.Millisecond) // Ensure FS modtime change require.NoError(t, os.WriteFile(envPath, []byte(`PULSE_AUTH_USER="updated"`), 0644)) require.Eventually(t, func() bool { Mu.RLock() defer Mu.RUnlock() return cfg.AuthUser == "updated" }, 1*time.Second, 10*time.Millisecond) // 2. Update mock.env time.Sleep(100 * time.Millisecond) require.NoError(t, os.WriteFile(mockEnvPath, []byte(`PULSE_MOCK_TEST="2"`), 0644)) require.Eventually(t, func() bool { return mockCalled }, 1*time.Second, 10*time.Millisecond) assert.Equal(t, "2", os.Getenv("PULSE_MOCK_TEST")) // 3. Update api_tokens.json // Write valid JSON time.Sleep(100 * time.Millisecond) // We need to write to file that Persistence reads. // ReloadAPITokens uses globalPersistence to load. tokens := []APITokenRecord{{ID: "new", Hash: "hash", Name: "New"}} require.NoError(t, p.SaveAPITokens(tokens)) // Waiting for polling to pick up change in file modification // persistence.SaveAPITokens writes to the file. require.Eventually(t, func() bool { return tokenCalled }, 1*time.Second, 10*time.Millisecond) Mu.RLock() defer Mu.RUnlock() assert.Len(t, cfg.APITokens, 1) } func TestConfigWatcher_ReloadConfig_APITokens(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{}, } cw, err := NewConfigWatcher(cfg) require.NoError(t, err) // Scenario 1: Add tokens via .env (when APITokens empty) envContent := `API_TOKEN="token1" API_TOKENS="token2,token3"` require.NoError(t, os.WriteFile(envPath, []byte(envContent), 0644)) cw.reloadConfig() assert.Len(t, cfg.APITokens, 3) assert.True(t, cfg.HasAPITokens()) // Scenario 2: Legacy tokens ignored if APITokens not empty (manually added via UI/Persistence) // Let's simulate that by adding a token directly to config cfg.APITokens = []APITokenRecord{{ID: "id", Hash: "hash"}} envContentUpdated := `API_TOKEN="tokenRefused"` require.NoError(t, os.WriteFile(envPath, []byte(envContentUpdated), 0644)) cw.reloadConfig() // Should still match manual config, ignoring .env 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_ReloadMockConfig_LocalOverride(t *testing.T) { tempDir := t.TempDir() mockEnvPath := filepath.Join(tempDir, "mock.env") mockEnvLocalPath := filepath.Join(tempDir, "mock.env.local") cfg := &Config{} cw := &ConfigWatcher{ config: cfg, mockEnvPath: mockEnvPath, } require.NoError(t, os.WriteFile(mockEnvPath, []byte(`PULSE_MOCK_TEST="base"`), 0644)) require.NoError(t, os.WriteFile(mockEnvLocalPath, []byte(`PULSE_MOCK_TEST="override"`), 0644)) cw.reloadMockConfig() assert.Equal(t, "override", os.Getenv("PULSE_MOCK_TEST")) os.Unsetenv("PULSE_MOCK_TEST") } func TestConfigWatcher_ReloadMockConfig_MissingFile(t *testing.T) { tempDir := t.TempDir() mockEnvPath := filepath.Join(tempDir, "mock.env") cw := &ConfigWatcher{ config: &Config{}, mockEnvPath: mockEnvPath, } // Should not panic or error cw.reloadMockConfig() } func TestConfigWatcher_ReloadAPITokens_Retries(t *testing.T) { tempDir := t.TempDir() p := NewConfigPersistence(tempDir) originalPersistence := globalPersistence globalPersistence = p defer func() { globalPersistence = originalPersistence }() 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, } // Should attempt retries and log errors but continue cw.reloadAPITokens() }