Files
pulse/internal/api/system_settings_handlers_test.go
T
courtmanr@gmail.com 893aa0b2cd Clear temperature SSH failure backoff on system-settings save (#1638)
The reset added in b45bd66b9 only fired when the temperature SSH key
file on disk changed (mtime/size). An operator who repairs SSH access
any other way — fixing authorized_keys on the host, repairing
known_hosts, restoring network reachability — still waited out a
backoff window that may have compounded toward fifteen minutes.

A system-settings save is the natural operator touchpoint after such a
repair, so the settings handler now fans ResetSSHFailureBackoff out to
every live tenant monitor after a successful save, clearing the
per-host temperature SSH backoff and the knownhosts keyscan backoff.
The reset touches in-memory retry timing only; nothing is persisted
and no request field controls it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:28:55 +01:00

639 lines
20 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/discovery"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
"github.com/rcourtman/pulse-go-rewrite/internal/websocket"
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
)
// MockMonitor implementation
type mockMonitor struct {
backupPollingEnabledCalls []bool
backupPollingIntervalCalls []time.Duration
pbsPollingIntervalCalls []time.Duration
pmgPollingIntervalCalls []time.Duration
resetSSHFailureBackoffCalls int
}
func (m *mockMonitor) GetDiscoveryService() *discovery.Service { return nil }
func (m *mockMonitor) StartDiscoveryService(ctx context.Context, wsHub *websocket.Hub, subnet string) {
}
func (m *mockMonitor) StopDiscoveryService() {}
func (m *mockMonitor) EnableTemperatureMonitoring() {}
func (m *mockMonitor) DisableTemperatureMonitoring() {}
func (m *mockMonitor) GetNotificationManager() *notifications.NotificationManager { return nil }
func (m *mockMonitor) SetBackupPollingEnabled(enabled bool) {
m.backupPollingEnabledCalls = append(m.backupPollingEnabledCalls, enabled)
}
func (m *mockMonitor) SetBackupPollingInterval(interval time.Duration) {
m.backupPollingIntervalCalls = append(m.backupPollingIntervalCalls, interval)
}
func (m *mockMonitor) SetPBSPollingInterval(interval time.Duration) {
m.pbsPollingIntervalCalls = append(m.pbsPollingIntervalCalls, interval)
}
func (m *mockMonitor) SetPMGPollingInterval(interval time.Duration) {
m.pmgPollingIntervalCalls = append(m.pmgPollingIntervalCalls, interval)
}
func (m *mockMonitor) ResetSSHFailureBackoff() {
m.resetSSHFailureBackoffCalls++
}
type mockTenantMonitorProvider struct {
orgID string
monitor *monitoring.Monitor
err error
}
func (m *mockTenantMonitorProvider) GetMonitor(orgID string) (*monitoring.Monitor, error) {
m.orgID = orgID
if m.err != nil {
return nil, m.err
}
return m.monitor, nil
}
func newTestSystemSettingsHandler(cfg *config.Config, persistence *config.ConfigPersistence, monitor SystemSettingsMonitor, reloadSystemSettingsFunc func(), reloadMonitorFunc func() error) *SystemSettingsHandler {
handler := NewSystemSettingsHandler(cfg, persistence, nil, nil, monitor, reloadSystemSettingsFunc, reloadMonitorFunc)
handler.mtMonitor = nil
return handler
}
func TestHandleGetSystemSettings(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
PVEPollingInterval: 30 * time.Second,
BackupPollingInterval: 1 * time.Hour,
EnableBackupPolling: true,
TemperatureMonitoringEnabled: true,
}
persistence := config.NewConfigPersistence(tempDir)
monitor := &mockMonitor{}
handler := newTestSystemSettingsHandler(cfg, persistence, monitor, func() {}, func() error { return nil })
// Save some settings first
initialSettings := config.DefaultSystemSettings()
initialSettings.Theme = "dark"
if err := persistence.SaveSystemSettings(*initialSettings); err != nil {
t.Fatalf("Failed to save initial settings: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/system-settings", nil)
rec := httptest.NewRecorder()
handler.HandleGetSystemSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
var response SystemSettingsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.Theme != "dark" {
t.Errorf("Expected theme 'dark', got '%s'", response.Theme)
}
if response.EnvOverrides == nil {
t.Fatalf("expected envOverrides to normalize to an empty map")
}
}
func TestHandleGetSystemSettings_DisablesAutoUpdatesOnRC(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
UpdateChannel: "rc",
AutoUpdateEnabled: true,
}
persistence := config.NewConfigPersistence(tempDir)
monitor := &mockMonitor{}
handler := newTestSystemSettingsHandler(cfg, persistence, monitor, func() {}, func() error { return nil })
initialSettings := config.DefaultSystemSettings()
initialSettings.UpdateChannel = "rc"
initialSettings.AutoUpdateEnabled = true
if err := persistence.SaveSystemSettings(*initialSettings); err != nil {
t.Fatalf("Failed to save initial settings: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/system-settings", nil)
rec := httptest.NewRecorder()
handler.HandleGetSystemSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
var response struct {
UpdateChannel string `json:"updateChannel"`
AutoUpdateEnabled bool `json:"autoUpdateEnabled"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.UpdateChannel != "rc" {
t.Fatalf("expected updateChannel rc, got %q", response.UpdateChannel)
}
if response.AutoUpdateEnabled {
t.Fatalf("expected autoUpdateEnabled to be normalized off on rc")
}
}
func TestHandleGetSystemSettings_UsesRuntimeRCWhenPersistedChannelMissing(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
UpdateChannel: "rc",
AutoUpdateEnabled: true,
}
persistence := config.NewConfigPersistence(tempDir)
monitor := &mockMonitor{}
handler := newTestSystemSettingsHandler(cfg, persistence, monitor, func() {}, func() error { return nil })
initialSettings := config.DefaultSystemSettings()
initialSettings.AutoUpdateEnabled = true
if err := persistence.SaveSystemSettings(*initialSettings); err != nil {
t.Fatalf("Failed to save initial settings: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/system-settings", nil)
rec := httptest.NewRecorder()
handler.HandleGetSystemSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
var response struct {
UpdateChannel string `json:"updateChannel"`
AutoUpdateEnabled bool `json:"autoUpdateEnabled"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.UpdateChannel != "rc" {
t.Fatalf("expected runtime updateChannel rc, got %q", response.UpdateChannel)
}
if response.AutoUpdateEnabled {
t.Fatalf("expected autoUpdateEnabled to be normalized off on runtime rc")
}
}
func TestHandleGetSystemSettings_LoadError(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{DataPath: tempDir}
persistence := config.NewConfigPersistence(tempDir)
handler := newTestSystemSettingsHandler(cfg, persistence, &mockMonitor{}, func() {}, func() error { return nil })
// Write invalid JSON
systemFile := filepath.Join(tempDir, "system.json")
if err := os.MkdirAll(tempDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(systemFile, []byte("{invalid json"), 0644); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/system-settings", nil)
rec := httptest.NewRecorder()
handler.HandleGetSystemSettings(rec, req)
// Should fallback to defaults
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
}
func TestHandleUpdateSystemSettings_Basic(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
}
persistence := config.NewConfigPersistence(tempDir)
monitor := &mockMonitor{}
handler := newTestSystemSettingsHandler(cfg, persistence, monitor, func() {}, func() error { return nil })
// Setup Authentication (API Token)
tokenVal := "testtoken123"
tokenHash := internalauth.HashAPIToken(tokenVal)
cfg.APITokens = []config.APITokenRecord{
{
ID: "token1",
Hash: tokenHash,
Name: "Test Token",
},
}
updates := map[string]interface{}{
"theme": "light",
"pvePollingInterval": 60,
}
body, _ := json.Marshal(updates)
req := httptest.NewRequest(http.MethodPost, "/api/system-settings", bytes.NewReader(body))
req.Header.Set("X-API-Token", tokenVal)
rec := httptest.NewRecorder()
handler.HandleUpdateSystemSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body: %s", rec.Code, rec.Body.String())
}
// Verify persistence
loaded, err := persistence.LoadSystemSettings()
if err != nil {
t.Fatalf("Failed to load settings: %v", err)
}
if loaded.Theme != "light" {
t.Errorf("Expected theme 'light', got '%s'", loaded.Theme)
}
if loaded.PVEPollingInterval != 60 {
t.Errorf("Expected PVEPollingInterval 60, got %d", loaded.PVEPollingInterval)
}
// Verify config update
if cfg.PVEPollingInterval != 60*time.Second {
t.Errorf("Config was not updated. Expected 60s, got %v", cfg.PVEPollingInterval)
}
}
// Regression test for #1619: backupPollingInterval, pbsPollingInterval,
// pmgPollingInterval and backupPollingEnabled must be pushed into live
// monitors on save. Mutating h.config alone never reaches a running monitor
// (each polls a detached config copy), and these settings do not trigger a
// monitor reload.
func TestHandleUpdateSystemSettings_PollingCadencePushedToLiveMonitors(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
EnableBackupPolling: true,
BackupPollingInterval: time.Hour,
PBSPollingInterval: time.Minute,
PMGPollingInterval: time.Minute,
}
persistence := config.NewConfigPersistence(tempDir)
monitor := &mockMonitor{}
reloadCalled := false
handler := newTestSystemSettingsHandler(cfg, persistence, monitor, func() {}, func() error {
reloadCalled = true
return nil
})
tokenVal := "testtoken123"
cfg.APITokens = []config.APITokenRecord{
{ID: "token1", Hash: internalauth.HashAPIToken(tokenVal), Name: "Test Token"},
}
updates := map[string]interface{}{
"backupPollingInterval": 600,
"pbsPollingInterval": 90,
"pmgPollingInterval": 120,
"backupPollingEnabled": false,
}
body, _ := json.Marshal(updates)
req := httptest.NewRequest(http.MethodPost, "/api/system-settings", bytes.NewReader(body))
req.Header.Set("X-API-Token", tokenVal)
rec := httptest.NewRecorder()
handler.HandleUpdateSystemSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body: %s", rec.Code, rec.Body.String())
}
if len(monitor.backupPollingIntervalCalls) != 1 || monitor.backupPollingIntervalCalls[0] != 600*time.Second {
t.Errorf("expected SetBackupPollingInterval(600s) on live monitor, got %v", monitor.backupPollingIntervalCalls)
}
if len(monitor.pbsPollingIntervalCalls) != 1 || monitor.pbsPollingIntervalCalls[0] != 90*time.Second {
t.Errorf("expected SetPBSPollingInterval(90s) on live monitor, got %v", monitor.pbsPollingIntervalCalls)
}
if len(monitor.pmgPollingIntervalCalls) != 1 || monitor.pmgPollingIntervalCalls[0] != 120*time.Second {
t.Errorf("expected SetPMGPollingInterval(120s) on live monitor, got %v", monitor.pmgPollingIntervalCalls)
}
if len(monitor.backupPollingEnabledCalls) != 1 || monitor.backupPollingEnabledCalls[0] != false {
t.Errorf("expected SetBackupPollingEnabled(false) on live monitor, got %v", monitor.backupPollingEnabledCalls)
}
if reloadCalled {
t.Error("polling cadence settings should not trigger a full monitor reload")
}
// The base config still updates so future tenant monitors inherit the values.
if cfg.BackupPollingInterval != 600*time.Second {
t.Errorf("expected base config BackupPollingInterval 600s, got %v", cfg.BackupPollingInterval)
}
if cfg.PBSPollingInterval != 90*time.Second {
t.Errorf("expected base config PBSPollingInterval 90s, got %v", cfg.PBSPollingInterval)
}
if cfg.PMGPollingInterval != 120*time.Second {
t.Errorf("expected base config PMGPollingInterval 120s, got %v", cfg.PMGPollingInterval)
}
if cfg.EnableBackupPolling {
t.Error("expected base config EnableBackupPolling false")
}
}
func TestHandleUpdateSystemSettings_DisablesAutoUpdatesWhenRCSelected(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
}
persistence := config.NewConfigPersistence(tempDir)
monitor := &mockMonitor{}
handler := newTestSystemSettingsHandler(cfg, persistence, monitor, func() {}, func() error { return nil })
tokenVal := "testtoken123"
tokenHash := internalauth.HashAPIToken(tokenVal)
cfg.APITokens = []config.APITokenRecord{
{
ID: "token1",
Hash: tokenHash,
Name: "Test Token",
},
}
updates := map[string]interface{}{
"updateChannel": "rc",
"autoUpdateEnabled": true,
}
body, _ := json.Marshal(updates)
req := httptest.NewRequest(http.MethodPost, "/api/system-settings", bytes.NewReader(body))
req.Header.Set("X-API-Token", tokenVal)
rec := httptest.NewRecorder()
handler.HandleUpdateSystemSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body: %s", rec.Code, rec.Body.String())
}
loaded, err := persistence.LoadSystemSettings()
if err != nil {
t.Fatalf("Failed to load settings: %v", err)
}
if loaded.UpdateChannel != "rc" {
t.Fatalf("expected saved updateChannel rc, got %q", loaded.UpdateChannel)
}
if loaded.AutoUpdateEnabled {
t.Fatalf("expected saved autoUpdateEnabled to be false on rc")
}
if cfg.AutoUpdateEnabled {
t.Fatalf("expected runtime autoUpdateEnabled to be false on rc")
}
}
func TestHandleUpdateSystemSettings_UsesRuntimeRCWhenChannelOmitted(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
UpdateChannel: "rc",
}
persistence := config.NewConfigPersistence(tempDir)
monitor := &mockMonitor{}
handler := newTestSystemSettingsHandler(cfg, persistence, monitor, func() {}, func() error { return nil })
tokenVal := "testtoken123"
tokenHash := internalauth.HashAPIToken(tokenVal)
cfg.APITokens = []config.APITokenRecord{
{
ID: "token1",
Hash: tokenHash,
Name: "Test Token",
},
}
initialSettings := config.DefaultSystemSettings()
if err := persistence.SaveSystemSettings(*initialSettings); err != nil {
t.Fatalf("Failed to save initial settings: %v", err)
}
updates := map[string]interface{}{
"autoUpdateEnabled": true,
}
body, _ := json.Marshal(updates)
req := httptest.NewRequest(http.MethodPost, "/api/system-settings", bytes.NewReader(body))
req.Header.Set("X-API-Token", tokenVal)
rec := httptest.NewRecorder()
handler.HandleUpdateSystemSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body: %s", rec.Code, rec.Body.String())
}
loaded, err := persistence.LoadSystemSettings()
if err != nil {
t.Fatalf("Failed to load settings: %v", err)
}
if loaded.UpdateChannel != "rc" {
t.Fatalf("expected saved updateChannel rc from runtime fallback, got %q", loaded.UpdateChannel)
}
if loaded.AutoUpdateEnabled {
t.Fatalf("expected saved autoUpdateEnabled to remain false on runtime rc")
}
if cfg.AutoUpdateEnabled {
t.Fatalf("expected runtime autoUpdateEnabled to remain false on runtime rc")
}
}
func TestHandleUpdateSystemSettings_DiscoveryConfigAppliedFromCamelCasePayload(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
Discovery: config.DefaultDiscoveryConfig(),
}
persistence := config.NewConfigPersistence(tempDir)
monitor := &mockMonitor{}
handler := newTestSystemSettingsHandler(cfg, persistence, monitor, func() {}, func() error { return nil })
tokenVal := "testtoken123"
tokenHash := internalauth.HashAPIToken(tokenVal)
cfg.APITokens = []config.APITokenRecord{
{ID: "token1", Hash: tokenHash, Name: "Test Token"},
}
updates := map[string]interface{}{
"discoveryConfig": map[string]interface{}{
"environmentOverride": "docker-bridge",
"subnetAllowlist": []string{"10.0.0.0/8"},
"subnetBlocklist": []string{"169.254.0.0/16"},
"maxHostsPerScan": 77,
"maxConcurrent": 11,
"enableReverseDns": false,
"scanGateways": false,
"dialTimeoutMs": 1500,
"httpTimeoutMs": 2300,
},
}
body, _ := json.Marshal(updates)
req := httptest.NewRequest(http.MethodPost, "/api/system-settings", bytes.NewReader(body))
req.Header.Set("X-API-Token", tokenVal)
rec := httptest.NewRecorder()
handler.HandleUpdateSystemSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body: %s", rec.Code, rec.Body.String())
}
loaded, err := persistence.LoadSystemSettings()
if err != nil {
t.Fatalf("Failed to load settings: %v", err)
}
got := loaded.DiscoveryConfig
if got.EnvironmentOverride != "docker-bridge" {
t.Fatalf("EnvironmentOverride = %q, want %q", got.EnvironmentOverride, "docker-bridge")
}
if got.MaxHostsPerScan != 77 {
t.Fatalf("MaxHostsPerScan = %d, want 77", got.MaxHostsPerScan)
}
if got.MaxConcurrent != 11 {
t.Fatalf("MaxConcurrent = %d, want 11", got.MaxConcurrent)
}
if got.EnableReverseDNS {
t.Fatalf("EnableReverseDNS = true, want false")
}
if got.ScanGateways {
t.Fatalf("ScanGateways = true, want false")
}
if got.DialTimeout != 1500 {
t.Fatalf("DialTimeout = %d, want 1500", got.DialTimeout)
}
if got.HTTPTimeout != 2300 {
t.Fatalf("HTTPTimeout = %d, want 2300", got.HTTPTimeout)
}
if len(got.SubnetAllowlist) != 1 || got.SubnetAllowlist[0] != "10.0.0.0/8" {
t.Fatalf("SubnetAllowlist = %v, want [10.0.0.0/8]", got.SubnetAllowlist)
}
if len(got.SubnetBlocklist) != 1 || got.SubnetBlocklist[0] != "169.254.0.0/16" {
t.Fatalf("SubnetBlocklist = %v, want [169.254.0.0/16]", got.SubnetBlocklist)
}
if cfg.Discovery.EnvironmentOverride != "docker-bridge" {
t.Fatalf("runtime config EnvironmentOverride = %q, want %q", cfg.Discovery.EnvironmentOverride, "docker-bridge")
}
}
func TestHandleUpdateSystemSettings_Unauthorized(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
AuthUser: "admin",
AuthPass: "password", // Requires auth
}
persistence := config.NewConfigPersistence(tempDir)
handler := newTestSystemSettingsHandler(cfg, persistence, &mockMonitor{}, func() {}, func() error { return nil })
req := httptest.NewRequest(http.MethodPost, "/api/system-settings", nil)
rec := httptest.NewRecorder()
handler.HandleUpdateSystemSettings(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d", rec.Code)
}
}
func TestHandleUpdateSystemSettings_Validation(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
}
persistence := config.NewConfigPersistence(tempDir)
handler := newTestSystemSettingsHandler(cfg, persistence, &mockMonitor{}, func() {}, func() error { return nil })
// Setup Auth
tokenVal := "testtoken123"
tokenHash := internalauth.HashAPIToken(tokenVal)
cfg.APITokens = []config.APITokenRecord{
{ID: "token1", Hash: tokenHash},
}
updates := map[string]interface{}{
"pvePollingInterval": -1, // Invalid
}
body, _ := json.Marshal(updates)
req := httptest.NewRequest(http.MethodPost, "/api/system-settings", bytes.NewReader(body))
req.Header.Set("X-API-Token", tokenVal)
rec := httptest.NewRecorder()
handler.HandleUpdateSystemSettings(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", rec.Code)
}
}
func TestSystemSettingsHandler_GetMonitor_UsesTenantMonitorInterface(t *testing.T) {
tenantMonitor := &monitoring.Monitor{}
defaultMonitor := &mockMonitor{}
provider := &mockTenantMonitorProvider{monitor: tenantMonitor}
handler := &SystemSettingsHandler{
mtMonitor: provider,
defaultMonitor: defaultMonitor,
}
got := handler.getMonitor(context.Background())
if got != tenantMonitor {
t.Fatalf("expected tenant monitor, got %#v", got)
}
if provider.orgID != "default" {
t.Fatalf("expected org lookup for default context org, got %q", provider.orgID)
}
}
func TestSystemSettingsHandler_GetMonitor_FallsBackToLegacyOnTenantError(t *testing.T) {
defaultMonitor := &mockMonitor{}
provider := &mockTenantMonitorProvider{err: errors.New("boom")}
handler := &SystemSettingsHandler{
mtMonitor: provider,
defaultMonitor: defaultMonitor,
}
got := handler.getMonitor(context.WithValue(context.Background(), OrgIDContextKey, "acme"))
if got != defaultMonitor {
t.Fatalf("expected legacy monitor fallback, got %#v", got)
}
if provider.orgID != "acme" {
t.Fatalf("expected tenant org lookup for acme, got %q", provider.orgID)
}
}