mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-29 03:50:18 +00:00
- Remove unused envconfig tags (BackendHost, FrontendHost, etc.) - Remove APITokenEnabled (infer from token count) - Remove IframeEmbeddingAllow, Port, Debug, ConcurrentPolling - Clean up temperature proxy comments from ClusterEndpoint - Simplify API token diagnostic to use config field directly
473 lines
13 KiB
Go
473 lines
13 KiB
Go
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()
|
|
}
|