mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 18:45:53 +00:00
b8d3a768bd
Serialize API-token disk reload snapshots with live token mutations so a stale watcher read cannot overwrite a newer rename or revoke. Add deterministic rename and revoke race coverage and record the governed concurrency boundary. Change-source: pulse-maintainer
649 lines
17 KiB
Go
649 lines
17 KiB
Go
package config
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type blockingTokenReadFileSystem struct {
|
|
FileSystem
|
|
target string
|
|
readStarted chan struct{}
|
|
releaseRead chan struct{}
|
|
once sync.Once
|
|
}
|
|
|
|
func (fs *blockingTokenReadFileSystem) ReadFile(name string) ([]byte, error) {
|
|
data, err := fs.FileSystem.ReadFile(name)
|
|
if name == fs.target {
|
|
fs.once.Do(func() { close(fs.readStarted) })
|
|
<-fs.releaseRead
|
|
}
|
|
return data, err
|
|
}
|
|
|
|
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_StopWaitsForBackgroundWorker(t *testing.T) {
|
|
cw := &ConfigWatcher{
|
|
stopChan: make(chan struct{}),
|
|
}
|
|
|
|
workerDone := make(chan struct{})
|
|
cw.wg.Add(1)
|
|
go func() {
|
|
defer cw.wg.Done()
|
|
<-cw.stopChan
|
|
time.Sleep(25 * time.Millisecond)
|
|
close(workerDone)
|
|
}()
|
|
|
|
cw.Stop()
|
|
|
|
select {
|
|
case <-workerDone:
|
|
default:
|
|
t.Fatal("Stop returned before watcher worker exited")
|
|
}
|
|
}
|
|
|
|
func TestConfigWatcher_StartWatchesFilesWithoutDirectoryFanout(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
envPath := filepath.Join(tempDir, ".env")
|
|
apiTokensPath := filepath.Join(tempDir, "api_tokens.json")
|
|
require.NoError(t, os.WriteFile(envPath, []byte("PULSE_AUTH_USER=admin\n"), 0644))
|
|
require.NoError(t, os.WriteFile(apiTokensPath, []byte("[]"), 0644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".env.tmp.123"), []byte("stale"), 0644))
|
|
|
|
t.Setenv("PULSE_AUTH_CONFIG_DIR", tempDir)
|
|
cw, err := NewConfigWatcher(&Config{ConfigPath: tempDir})
|
|
require.NoError(t, err)
|
|
cw.pollInterval = time.Hour
|
|
|
|
require.NoError(t, cw.Start())
|
|
defer cw.Stop()
|
|
|
|
watched := cw.watcher.WatchList()
|
|
assert.Contains(t, watched, envPath)
|
|
assert.Contains(t, watched, apiTokensPath)
|
|
assert.NotContains(t, watched, tempDir)
|
|
assert.NotContains(t, watched, filepath.Join(tempDir, ".env.tmp.123"))
|
|
}
|
|
|
|
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.NotEqual(t, "secret", cfg.AuthPass)
|
|
assert.True(t, IsPasswordHashed(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.NotEqual(t, "newsecret", cfg.AuthPass)
|
|
assert.True(t, IsPasswordHashed(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_ReloadAPITokensDoesNotOverwriteConcurrentMutation(t *testing.T) {
|
|
oldTokens := []APITokenRecord{{
|
|
ID: "token-1",
|
|
Name: "Old name",
|
|
Hash: "hash-1",
|
|
Scopes: []string{ScopeWildcard},
|
|
OrgID: "default",
|
|
CreatedAt: time.Now().UTC(),
|
|
}}
|
|
|
|
for _, tt := range []struct {
|
|
name string
|
|
newTokens []APITokenRecord
|
|
}{
|
|
{
|
|
name: "rename",
|
|
newTokens: []APITokenRecord{{
|
|
ID: "token-1",
|
|
Name: "Renamed token",
|
|
Hash: "hash-1",
|
|
Scopes: []string{ScopeWildcard},
|
|
OrgID: "default",
|
|
CreatedAt: oldTokens[0].CreatedAt,
|
|
}},
|
|
},
|
|
{name: "revoke", newTokens: []APITokenRecord{}},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
watcherPersistence := NewConfigPersistence(tempDir)
|
|
require.NoError(t, watcherPersistence.SaveAPITokens(oldTokens))
|
|
writerPersistence := NewConfigPersistence(tempDir)
|
|
cfg := &Config{APITokens: append([]APITokenRecord(nil), oldTokens...)}
|
|
readFS := &blockingTokenReadFileSystem{
|
|
FileSystem: watcherPersistence.fs,
|
|
target: watcherPersistence.apiTokensFile,
|
|
readStarted: make(chan struct{}),
|
|
releaseRead: make(chan struct{}),
|
|
}
|
|
watcherPersistence.fs = readFS
|
|
cw := &ConfigWatcher{
|
|
config: cfg,
|
|
apiTokensPath: watcherPersistence.apiTokensFile,
|
|
persistence: watcherPersistence,
|
|
stopChan: make(chan struct{}),
|
|
}
|
|
|
|
reloadDone := make(chan struct{})
|
|
go func() {
|
|
cw.reloadAPITokens()
|
|
close(reloadDone)
|
|
}()
|
|
<-readFS.readStarted
|
|
|
|
writerLocked := make(chan struct{})
|
|
writerDone := make(chan error, 1)
|
|
go func() {
|
|
Mu.Lock()
|
|
close(writerLocked)
|
|
cfg.APITokens = append([]APITokenRecord(nil), tt.newTokens...)
|
|
err := writerPersistence.SaveAPITokens(cfg.APITokens)
|
|
Mu.Unlock()
|
|
writerDone <- err
|
|
}()
|
|
|
|
// On the regressed implementation the writer acquires Mu while the watcher
|
|
// is holding an old disk snapshot. On the fixed implementation it remains
|
|
// queued until that snapshot has been applied, so its newer mutation wins.
|
|
select {
|
|
case <-writerLocked:
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
close(readFS.releaseRead)
|
|
|
|
require.NoError(t, <-writerDone)
|
|
<-reloadDone
|
|
|
|
Mu.RLock()
|
|
runtimeTokens := append([]APITokenRecord(nil), cfg.APITokens...)
|
|
Mu.RUnlock()
|
|
require.Len(t, runtimeTokens, len(tt.newTokens))
|
|
if len(tt.newTokens) > 0 {
|
|
assert.Equal(t, tt.newTokens, runtimeTokens)
|
|
}
|
|
|
|
persisted, err := writerPersistence.LoadAPITokens()
|
|
require.NoError(t, err)
|
|
require.Len(t, persisted, len(tt.newTokens))
|
|
if len(tt.newTokens) > 0 {
|
|
assert.Equal(t, tt.newTokens, persisted)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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",
|
|
}
|
|
cw, err := NewConfigWatcher(cfg)
|
|
require.NoError(t, err)
|
|
|
|
oldPass, err := hashPasswordFn("oldPass")
|
|
require.NoError(t, err)
|
|
cfg.AuthPass = oldPass
|
|
|
|
// 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.NotEqual(t, "newPass", cfg.AuthPass)
|
|
assert.True(t, IsPasswordHashed(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_HashFailureKeepsExistingPassword(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
envPath := filepath.Join(tempDir, ".env")
|
|
t.Setenv("PULSE_AUTH_CONFIG_DIR", tempDir)
|
|
|
|
existingPass, err := hashPasswordFn("existingPass")
|
|
require.NoError(t, err)
|
|
|
|
cfg := &Config{
|
|
AuthUser: "admin",
|
|
AuthPass: existingPass,
|
|
}
|
|
cw, err := NewConfigWatcher(cfg)
|
|
require.NoError(t, err)
|
|
|
|
originalHashPasswordFn := hashPasswordFn
|
|
hashPasswordFn = func(string) (string, error) {
|
|
return "", errors.New("boom")
|
|
}
|
|
t.Cleanup(func() {
|
|
hashPasswordFn = originalHashPasswordFn
|
|
})
|
|
|
|
envContent := `PULSE_AUTH_USER="admin"
|
|
PULSE_AUTH_PASS="newPass"`
|
|
require.NoError(t, os.WriteFile(envPath, []byte(envContent), 0644))
|
|
|
|
cw.reloadConfig()
|
|
|
|
assert.Equal(t, existingPass, 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)
|
|
previousMockValue, hadPreviousMockValue := os.LookupEnv("PULSE_MOCK_TEST")
|
|
require.NoError(t, os.Unsetenv("PULSE_MOCK_TEST"))
|
|
t.Cleanup(func() {
|
|
if hadPreviousMockValue {
|
|
_ = os.Setenv("PULSE_MOCK_TEST", previousMockValue)
|
|
} else {
|
|
_ = os.Unsetenv("PULSE_MOCK_TEST")
|
|
}
|
|
})
|
|
|
|
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_MissingEnvPreservesProcessMockSetting(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
t.Setenv("PULSE_AUTH_CONFIG_DIR", tempDir)
|
|
t.Setenv("PULSE_MOCK_TEST", "deployment-owned")
|
|
|
|
cw, err := NewConfigWatcher(&Config{})
|
|
require.NoError(t, err)
|
|
|
|
cw.reloadConfig()
|
|
assert.Equal(t, "deployment-owned", os.Getenv("PULSE_MOCK_TEST"))
|
|
}
|
|
|
|
func TestConfigWatcher_ReloadConfig_RestoresProcessMockSettingAfterFileOverride(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
envPath := filepath.Join(tempDir, ".env")
|
|
t.Setenv("PULSE_AUTH_CONFIG_DIR", tempDir)
|
|
t.Setenv("PULSE_MOCK_TEST", "deployment-owned")
|
|
|
|
cw, err := NewConfigWatcher(&Config{})
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, os.WriteFile(envPath, []byte(`PULSE_MOCK_TEST="file-owned"`), 0644))
|
|
cw.reloadConfig()
|
|
require.Equal(t, "file-owned", os.Getenv("PULSE_MOCK_TEST"))
|
|
|
|
require.NoError(t, os.WriteFile(envPath, []byte(""), 0644))
|
|
cw.reloadConfig()
|
|
assert.Equal(t, "deployment-owned", 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()
|
|
}
|