package config_test import ( "bytes" "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "io" "os" "path/filepath" "reflect" "testing" "time" "github.com/rcourtman/pulse-go-rewrite/internal/alerts" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/notifications" "golang.org/x/crypto/pbkdf2" ) func TestSaveAlertConfig_PreservesStorageOverrideHysteresis(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } cfg := alerts.AlertConfig{ Enabled: true, StorageDefault: alerts.HysteresisThreshold{Trigger: 85, Clear: 80}, HysteresisMargin: 5.0, Overrides: map[string]alerts.ThresholdConfig{ "storage-123": { Usage: &alerts.HysteresisThreshold{Trigger: 90, Clear: 0}, }, }, } if err := cp.SaveAlertConfig(cfg); err != nil { t.Fatalf("SaveAlertConfig: %v", err) } loaded, err := cp.LoadAlertConfig() if err != nil { t.Fatalf("LoadAlertConfig: %v", err) } override, ok := loaded.Overrides["storage-123"] if !ok { t.Fatalf("storage override missing after load: %+v", loaded.Overrides) } if override.Usage == nil { t.Fatalf("usage threshold nil after load") } if got, want := override.Usage.Trigger, 90.0; got != want { t.Fatalf("trigger mismatch: got %v want %v", got, want) } if got, want := override.Usage.Clear, 85.0; got != want { t.Fatalf("clear threshold mismatch: got %v want %v", got, want) } } func TestSaveAlertConfig_DoesNotOverwriteExistingClear(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } cfg := alerts.AlertConfig{ Enabled: true, HysteresisMargin: 5.0, StorageDefault: alerts.HysteresisThreshold{Trigger: 85, Clear: 80}, Overrides: map[string]alerts.ThresholdConfig{ "storage-456": { Usage: &alerts.HysteresisThreshold{Trigger: 92, Clear: 88}, }, }, } if err := cp.SaveAlertConfig(cfg); err != nil { t.Fatalf("SaveAlertConfig: %v", err) } loaded, err := cp.LoadAlertConfig() if err != nil { t.Fatalf("LoadAlertConfig: %v", err) } override := loaded.Overrides["storage-456"] if override.Usage == nil { t.Fatalf("usage threshold nil") } if got, want := override.Usage.Clear, 88.0; got != want { t.Fatalf("clear threshold changed unexpectedly: got %v want %v", got, want) } } func TestSaveAlertConfig_NormalizesAgentDefaults(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Config with nil/zero AgentDefaults - should get defaults cfg := alerts.AlertConfig{ Enabled: true, StorageDefault: alerts.HysteresisThreshold{Trigger: 85, Clear: 80}, AgentDefaults: alerts.ThresholdConfig{}, // Empty - needs defaults } if err := cp.SaveAlertConfig(cfg); err != nil { t.Fatalf("SaveAlertConfig: %v", err) } loaded, err := cp.LoadAlertConfig() if err != nil { t.Fatalf("LoadAlertConfig: %v", err) } // Verify agent defaults were applied if loaded.AgentDefaults.CPU == nil { t.Fatal("CPU defaults should be set") } if loaded.AgentDefaults.CPU.Trigger != 80 { t.Errorf("CPU trigger = %v, want 80", loaded.AgentDefaults.CPU.Trigger) } if loaded.AgentDefaults.Memory == nil { t.Fatal("Memory defaults should be set") } if loaded.AgentDefaults.Memory.Trigger != 85 { t.Errorf("Memory trigger = %v, want 85", loaded.AgentDefaults.Memory.Trigger) } if loaded.AgentDefaults.Disk == nil { t.Fatal("Disk defaults should be set") } if loaded.AgentDefaults.Disk.Trigger != 90 { t.Errorf("Disk trigger = %v, want 90", loaded.AgentDefaults.Disk.Trigger) } } func TestSaveAlertConfig_NormalizesAgentDefaultsClear(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Config with trigger set but clear=0 - should compute clear cfg := alerts.AlertConfig{ Enabled: true, StorageDefault: alerts.HysteresisThreshold{Trigger: 85, Clear: 80}, AgentDefaults: alerts.ThresholdConfig{ CPU: &alerts.HysteresisThreshold{Trigger: 90, Clear: 0}, Memory: &alerts.HysteresisThreshold{Trigger: 95, Clear: 0}, Disk: &alerts.HysteresisThreshold{Trigger: 92, Clear: 0}, }, } if err := cp.SaveAlertConfig(cfg); err != nil { t.Fatalf("SaveAlertConfig: %v", err) } loaded, err := cp.LoadAlertConfig() if err != nil { t.Fatalf("LoadAlertConfig: %v", err) } // Clear should be trigger - 5 if loaded.AgentDefaults.CPU.Clear != 85 { t.Errorf("CPU clear = %v, want 85", loaded.AgentDefaults.CPU.Clear) } if loaded.AgentDefaults.Memory.Clear != 90 { t.Errorf("Memory clear = %v, want 90", loaded.AgentDefaults.Memory.Clear) } if loaded.AgentDefaults.Disk.Clear != 87 { t.Errorf("Disk clear = %v, want 87", loaded.AgentDefaults.Disk.Clear) } } // TestSaveAlertConfig_AgentDefaultsZeroDisablesAlerting verifies that setting // Host Agent thresholds to 0 is preserved (fixes GitHub issue #864). // Setting a threshold to 0 should disable alerting for that metric. func TestSaveAlertConfig_AgentDefaultsZeroDisablesAlerting(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Config with Memory=0 to disable memory alerting for host agents cfg := alerts.AlertConfig{ Enabled: true, StorageDefault: alerts.HysteresisThreshold{Trigger: 85, Clear: 80}, AgentDefaults: alerts.ThresholdConfig{ CPU: &alerts.HysteresisThreshold{Trigger: 80, Clear: 75}, Memory: &alerts.HysteresisThreshold{Trigger: 0, Clear: 0}, // Disabled Disk: &alerts.HysteresisThreshold{Trigger: 90, Clear: 85}, }, } if err := cp.SaveAlertConfig(cfg); err != nil { t.Fatalf("SaveAlertConfig: %v", err) } loaded, err := cp.LoadAlertConfig() if err != nil { t.Fatalf("LoadAlertConfig: %v", err) } // Memory threshold should remain at 0 (disabled), not reset to default if loaded.AgentDefaults.Memory == nil { t.Fatal("Memory defaults should be preserved (not nil)") } if loaded.AgentDefaults.Memory.Trigger != 0 { t.Errorf("Memory trigger = %v, want 0 (disabled)", loaded.AgentDefaults.Memory.Trigger) } if loaded.AgentDefaults.Memory.Clear != 0 { t.Errorf("Memory clear = %v, want 0 (disabled)", loaded.AgentDefaults.Memory.Clear) } // CPU and Disk should still have their values if loaded.AgentDefaults.CPU.Trigger != 80 { t.Errorf("CPU trigger = %v, want 80", loaded.AgentDefaults.CPU.Trigger) } if loaded.AgentDefaults.Disk.Trigger != 90 { t.Errorf("Disk trigger = %v, want 90", loaded.AgentDefaults.Disk.Trigger) } } // TestSaveAlertConfig_StorageDefaultZeroDisablesAlerting verifies that setting // StorageDefault threshold to 0 is preserved to disable storage alerting. func TestSaveAlertConfig_StorageDefaultZeroDisablesAlerting(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Config with StorageDefault.Trigger=0 to disable storage alerting cfg := alerts.AlertConfig{ Enabled: true, StorageDefault: alerts.HysteresisThreshold{Trigger: 0, Clear: 0}, } if err := cp.SaveAlertConfig(cfg); err != nil { t.Fatalf("SaveAlertConfig: %v", err) } loaded, err := cp.LoadAlertConfig() if err != nil { t.Fatalf("LoadAlertConfig: %v", err) } // Storage threshold should remain at 0 (disabled), not reset to default if loaded.StorageDefault.Trigger != 0 { t.Errorf("StorageDefault trigger = %v, want 0 (disabled)", loaded.StorageDefault.Trigger) } if loaded.StorageDefault.Clear != 0 { t.Errorf("StorageDefault clear = %v, want 0 (disabled)", loaded.StorageDefault.Clear) } } // TestSaveAlertConfig_NodeTemperatureZeroDisablesAlerting verifies that setting // NodeDefaults.Temperature threshold to 0 is preserved to disable temperature alerting. func TestSaveAlertConfig_NodeTemperatureZeroDisablesAlerting(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Config with Temperature=0 to disable temperature alerting cfg := alerts.AlertConfig{ Enabled: true, StorageDefault: alerts.HysteresisThreshold{Trigger: 85, Clear: 80}, NodeDefaults: alerts.ThresholdConfig{ Temperature: &alerts.HysteresisThreshold{Trigger: 0, Clear: 0}, }, } if err := cp.SaveAlertConfig(cfg); err != nil { t.Fatalf("SaveAlertConfig: %v", err) } loaded, err := cp.LoadAlertConfig() if err != nil { t.Fatalf("LoadAlertConfig: %v", err) } // Temperature threshold should remain at 0 (disabled), not reset to default if loaded.NodeDefaults.Temperature == nil { t.Fatal("Temperature should be preserved (not nil)") } if loaded.NodeDefaults.Temperature.Trigger != 0 { t.Errorf("Temperature trigger = %v, want 0 (disabled)", loaded.NodeDefaults.Temperature.Trigger) } if loaded.NodeDefaults.Temperature.Clear != 0 { t.Errorf("Temperature clear = %v, want 0 (disabled)", loaded.NodeDefaults.Temperature.Clear) } } // TestSaveAlertConfig_AllThresholdsZeroDisablesAlerting is a comprehensive test // verifying that all threshold types can be set to 0 to disable alerting. func TestSaveAlertConfig_AllThresholdsZeroDisablesAlerting(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Config with all thresholds set to 0 to disable all alerting cfg := alerts.AlertConfig{ Enabled: true, StorageDefault: alerts.HysteresisThreshold{Trigger: 0, Clear: 0}, NodeDefaults: alerts.ThresholdConfig{ Temperature: &alerts.HysteresisThreshold{Trigger: 0, Clear: 0}, }, AgentDefaults: alerts.ThresholdConfig{ CPU: &alerts.HysteresisThreshold{Trigger: 0, Clear: 0}, Memory: &alerts.HysteresisThreshold{Trigger: 0, Clear: 0}, Disk: &alerts.HysteresisThreshold{Trigger: 0, Clear: 0}, }, } if err := cp.SaveAlertConfig(cfg); err != nil { t.Fatalf("SaveAlertConfig: %v", err) } loaded, err := cp.LoadAlertConfig() if err != nil { t.Fatalf("LoadAlertConfig: %v", err) } // All thresholds should remain at 0 if loaded.StorageDefault.Trigger != 0 { t.Errorf("StorageDefault trigger = %v, want 0", loaded.StorageDefault.Trigger) } if loaded.NodeDefaults.Temperature == nil || loaded.NodeDefaults.Temperature.Trigger != 0 { t.Errorf("Temperature trigger = %v, want 0", loaded.NodeDefaults.Temperature) } if loaded.AgentDefaults.CPU == nil || loaded.AgentDefaults.CPU.Trigger != 0 { t.Errorf("AgentDefaults.CPU trigger = %v, want 0", loaded.AgentDefaults.CPU) } if loaded.AgentDefaults.Memory == nil || loaded.AgentDefaults.Memory.Trigger != 0 { t.Errorf("AgentDefaults.Memory trigger = %v, want 0", loaded.AgentDefaults.Memory) } if loaded.AgentDefaults.Disk == nil || loaded.AgentDefaults.Disk.Trigger != 0 { t.Errorf("AgentDefaults.Disk trigger = %v, want 0", loaded.AgentDefaults.Disk) } } func TestAlertConfigPersistenceNormalizesDockerIgnoredPrefixes(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } cfg := alerts.AlertConfig{ Enabled: true, StorageDefault: alerts.HysteresisThreshold{Trigger: 85, Clear: 80}, DockerIgnoredContainerPrefixes: []string{ " Foo ", "foo", "Bar", " bar ", }, } if err := cp.SaveAlertConfig(cfg); err != nil { t.Fatalf("SaveAlertConfig: %v", err) } loaded, err := cp.LoadAlertConfig() if err != nil { t.Fatalf("LoadAlertConfig: %v", err) } expected := []string{"Foo", "Bar"} if !reflect.DeepEqual(loaded.DockerIgnoredContainerPrefixes, expected) { t.Fatalf("unexpected prefixes: got %v want %v", loaded.DockerIgnoredContainerPrefixes, expected) } } func TestLoadAlertConfigAppliesDefaults(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } raw := alerts.AlertConfig{ Enabled: false, TimeThresholds: map[string]int{"guest": 0, "node": 0}, DockerIgnoredContainerPrefixes: []string{" Runner "}, SnapshotDefaults: alerts.SnapshotAlertConfig{ Enabled: true, WarningDays: 20, CriticalDays: 10, WarningSizeGiB: 15, CriticalSizeGiB: 8, }, BackupDefaults: alerts.BackupAlertConfig{ Enabled: true, WarningDays: 12, CriticalDays: 8, }, NodeDefaults: alerts.ThresholdConfig{ // Use negative value to test "unset" case (negative means unset, 0 means disabled) Temperature: &alerts.HysteresisThreshold{Trigger: -1, Clear: 0}, }, } data, err := json.Marshal(raw) if err != nil { t.Fatalf("Marshal: %v", err) } if err := os.WriteFile(filepath.Join(tempDir, "alerts.json"), data, 0600); err != nil { t.Fatalf("WriteFile: %v", err) } loaded, err := cp.LoadAlertConfig() if err != nil { t.Fatalf("LoadAlertConfig: %v", err) } if got := loaded.TimeThresholds["guest"]; got != 5 { t.Fatalf("expected guest threshold default 5, got %d", got) } if got := loaded.TimeThresholds["node"]; got != 5 { t.Fatalf("expected node threshold default 5, got %d", got) } if loaded.NodeDefaults.Temperature == nil { t.Fatalf("expected node temperature defaults to be set") } // Negative trigger should be replaced with defaults (80/75) if loaded.NodeDefaults.Temperature.Trigger != 80 || loaded.NodeDefaults.Temperature.Clear != 75 { t.Fatalf("expected temperature defaults 80/75, got %+v", loaded.NodeDefaults.Temperature) } if !loaded.BackupDefaults.Enabled { t.Fatalf("expected backup defaults to remain enabled") } if loaded.BackupDefaults.WarningDays != 8 { t.Fatalf("expected backup warning normalized to 8, got %d", loaded.BackupDefaults.WarningDays) } if loaded.BackupDefaults.CriticalDays != 8 { t.Fatalf("expected backup critical normalized to 8, got %d", loaded.BackupDefaults.CriticalDays) } expectedPrefixes := []string{"Runner"} if !reflect.DeepEqual(loaded.DockerIgnoredContainerPrefixes, expectedPrefixes) { t.Fatalf("expected normalized prefixes %v, got %v", expectedPrefixes, loaded.DockerIgnoredContainerPrefixes) } if loaded.SnapshotDefaults.Enabled != true { t.Fatalf("expected snapshot defaults to preserve enabled state") } if loaded.SnapshotDefaults.WarningDays != 10 { t.Fatalf("expected snapshot warning days normalized to critical, got %d", loaded.SnapshotDefaults.WarningDays) } if loaded.SnapshotDefaults.CriticalDays != 10 { t.Fatalf("expected snapshot critical days preserved at 10, got %d", loaded.SnapshotDefaults.CriticalDays) } if loaded.SnapshotDefaults.WarningSizeGiB != 8 { t.Fatalf("expected snapshot warning size normalized to 8, got %.1f", loaded.SnapshotDefaults.WarningSizeGiB) } if loaded.SnapshotDefaults.CriticalSizeGiB != 8 { t.Fatalf("expected snapshot critical size preserved at 8, got %.1f", loaded.SnapshotDefaults.CriticalSizeGiB) } } func TestAppriseConfigPersistence(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } cfg := notifications.AppriseConfig{ Enabled: true, Targets: []string{" discord://token ", "", "mailto://alerts@example.com"}, CLIPath: " /usr/local/bin/apprise ", TimeoutSeconds: 3, } if err := cp.SaveAppriseConfig(cfg); err != nil { t.Fatalf("SaveAppriseConfig: %v", err) } loaded, err := cp.LoadAppriseConfig() if err != nil { t.Fatalf("LoadAppriseConfig: %v", err) } if !loaded.Enabled { t.Fatalf("expected config to remain enabled") } expectedTargets := []string{"discord://token", "mailto://alerts@example.com"} if !reflect.DeepEqual(loaded.Targets, expectedTargets) { t.Fatalf("unexpected targets: got %v want %v", loaded.Targets, expectedTargets) } if loaded.CLIPath != "apprise" { t.Fatalf("expected CLI path to be hardcoded to 'apprise' (security fix), got %q", loaded.CLIPath) } if loaded.TimeoutSeconds != 5 { t.Fatalf("expected timeout normalized to minimum 5 seconds, got %d", loaded.TimeoutSeconds) } // Clearing targets should disable the config on next load if err := cp.SaveAppriseConfig(notifications.AppriseConfig{Enabled: true}); err != nil { t.Fatalf("SaveAppriseConfig empty: %v", err) } empty, err := cp.LoadAppriseConfig() if err != nil { t.Fatalf("LoadAppriseConfig empty: %v", err) } if empty.Enabled { t.Fatalf("expected disabled configuration when no targets stored") } } func TestExportConfigIncludesAPITokens(t *testing.T) { t.Setenv("PULSE_DATA_DIR", t.TempDir()) tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } createdAt := time.Date(2023, 10, 1, 12, 0, 0, 0, time.UTC) tokens := []config.APITokenRecord{ { ID: "token-1", Name: "automation", Hash: "hash-1", Prefix: "hash-1", Suffix: "-0001", CreatedAt: createdAt, Scopes: []string{config.ScopeWildcard}, }, { ID: "token-2", Name: "metrics", Hash: "hash-2", Prefix: "hash-2", Suffix: "-0002", CreatedAt: createdAt.Add(time.Hour), Scopes: []string{config.ScopeMonitoringRead}, }, } if err := cp.SaveAPITokens(tokens); err != nil { t.Fatalf("SaveAPITokens: %v", err) } sso := &config.SSOConfig{ Providers: []config.SSOProvider{ { ID: "primary", Name: "Primary SSO", Type: config.SSOProviderTypeOIDC, Enabled: true, OIDC: &config.OIDCProviderConfig{ IssuerURL: "https://idp.example.com", ClientID: "pulse-client", }, }, }, DefaultProviderID: "primary", } if err := cp.SaveSSOConfig(sso); err != nil { t.Fatalf("SaveSSOConfig: %v", err) } passphrase := "strong-passphrase" exported, err := cp.ExportConfig(passphrase) if err != nil { t.Fatalf("ExportConfig: %v", err) } decoded := mustDecodeExport(t, exported, passphrase) if decoded.Version != "4.2" { t.Fatalf("expected export version 4.2, got %q", decoded.Version) } assertJSONEqual(t, decoded.APITokens, tokens, "api tokens") if decoded.SSO == nil { t.Fatalf("expected sso config in export") } assertJSONEqual(t, decoded.SSO, sso, "sso config") } func TestImportConfigTransactionalSuccess(t *testing.T) { const passphrase = "import-success" sourceDataDir := t.TempDir() t.Setenv("PULSE_DATA_DIR", sourceDataDir) sourceConfigDir := t.TempDir() source := config.NewConfigPersistence(sourceConfigDir) if err := source.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } newNodes := []config.PVEInstance{ { Name: "pve-new", Host: "https://pve-new.example:8006", User: "root@pam", MonitorVMs: true, MonitorStorage: true, }, } newPBS := []config.PBSInstance{ { Name: "pbs-new", Host: "https://pbs-new.example:8007", User: "pbs@pam", MonitorBackups: true, }, } if err := source.SaveNodesConfig(newNodes, newPBS, nil); err != nil { t.Fatalf("SaveNodesConfig: %v", err) } newAlerts := alerts.AlertConfig{ Enabled: true, HysteresisMargin: 3.5, StorageDefault: alerts.HysteresisThreshold{ Trigger: 70, Clear: 65, }, TimeThresholds: map[string]int{ "guest": 10, "node": 10, "storage": 10, "pbs": 10, }, Overrides: map[string]alerts.ThresholdConfig{ "node/pve-new": { CPU: &alerts.HysteresisThreshold{Trigger: 80, Clear: 72}, }, }, } if err := source.SaveAlertConfig(newAlerts); err != nil { t.Fatalf("SaveAlertConfig: %v", err) } newSystem := config.SystemSettings{ PBSPollingInterval: 45, PMGPollingInterval: 50, AutoUpdateEnabled: true, DiscoveryEnabled: false, DiscoverySubnet: "192.168.10.0/24", DiscoveryConfig: config.DefaultDiscoveryConfig(), Theme: "dark", AllowEmbedding: true, } if err := source.SaveSystemSettings(newSystem); err != nil { t.Fatalf("SaveSystemSettings: %v", err) } newTokens := []config.APITokenRecord{ { ID: "token-new-1", Name: "automation", Hash: "hash-new-1", Prefix: "hashn1", Suffix: "n1", CreatedAt: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), Scopes: []string{config.ScopeMonitoringRead, config.ScopeMonitoringWrite}, }, } if err := source.SaveAPITokens(newTokens); err != nil { t.Fatalf("SaveAPITokens: %v", err) } exported, err := source.ExportConfig(passphrase) if err != nil { t.Fatalf("ExportConfig: %v", err) } exportedData := mustDecodeExport(t, exported, passphrase) targetDataDir := t.TempDir() t.Setenv("PULSE_DATA_DIR", targetDataDir) targetConfigDir := t.TempDir() target := config.NewConfigPersistence(targetConfigDir) if err := target.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } oldNodes := []config.PVEInstance{ { Name: "pve-old", Host: "https://pve-old.example:8006", User: "root@pam", }, } if err := target.SaveNodesConfig(oldNodes, nil, nil); err != nil { t.Fatalf("SaveNodesConfig baseline: %v", err) } oldAlerts := alerts.AlertConfig{ Enabled: true, HysteresisMargin: 5, StorageDefault: alerts.HysteresisThreshold{ Trigger: 85, Clear: 80, }, Overrides: map[string]alerts.ThresholdConfig{}, } if err := target.SaveAlertConfig(oldAlerts); err != nil { t.Fatalf("SaveAlertConfig baseline: %v", err) } oldSystem := config.SystemSettings{ PBSPollingInterval: 120, PMGPollingInterval: 120, AutoUpdateEnabled: false, DiscoveryEnabled: true, DiscoverySubnet: "auto", DiscoveryConfig: config.DefaultDiscoveryConfig(), Theme: "light", } if err := target.SaveSystemSettings(oldSystem); err != nil { t.Fatalf("SaveSystemSettings baseline: %v", err) } oldTokens := []config.APITokenRecord{ { ID: "token-old-1", Name: "legacy", Hash: "hash-old-1", Prefix: "hasho1", Suffix: "o1", CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), Scopes: []string{config.ScopeWildcard}, }, } if err := target.SaveAPITokens(oldTokens); err != nil { t.Fatalf("SaveAPITokens baseline: %v", err) } if err := target.ImportConfig(exported, passphrase); err != nil { t.Fatalf("ImportConfig: %v", err) } nodesAfter, err := target.LoadNodesConfig() if err != nil { t.Fatalf("LoadNodesConfig: %v", err) } assertJSONEqual(t, nodesAfter, exportedData.Nodes, "nodes") alertsAfter, err := target.LoadAlertConfig() if err != nil { t.Fatalf("LoadAlertConfig: %v", err) } assertJSONEqual(t, alertsAfter, exportedData.Alerts, "alerts") systemAfter, err := target.LoadSystemSettings() if err != nil { t.Fatalf("LoadSystemSettings: %v", err) } if systemAfter == nil { t.Fatal("expected system settings after import") } assertJSONEqual(t, systemAfter, exportedData.System, "system settings") tokensAfter, err := target.LoadAPITokens() if err != nil { t.Fatalf("LoadAPITokens: %v", err) } assertJSONEqual(t, tokensAfter, exportedData.APITokens, "api tokens") tmpFiles, err := filepath.Glob(filepath.Join(targetConfigDir, "*.tmp")) if err != nil { t.Fatalf("Glob tmp files: %v", err) } if len(tmpFiles) != 0 { t.Fatalf("expected no tmp files after import, found %v", tmpFiles) } } func TestImportConfigRollbackOnFailure(t *testing.T) { const passphrase = "import-rollback" sourceDataDir := t.TempDir() t.Setenv("PULSE_DATA_DIR", sourceDataDir) sourceConfigDir := t.TempDir() source := config.NewConfigPersistence(sourceConfigDir) if err := source.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } newNodes := []config.PVEInstance{ { Name: "pve-new", Host: "https://pve-new.example:8006", User: "root@pam", }, } if err := source.SaveNodesConfig(newNodes, nil, nil); err != nil { t.Fatalf("SaveNodesConfig: %v", err) } newAlerts := alerts.AlertConfig{ Enabled: true, HysteresisMargin: 4, StorageDefault: alerts.HysteresisThreshold{ Trigger: 65, Clear: 60, }, Overrides: map[string]alerts.ThresholdConfig{}, } if err := source.SaveAlertConfig(newAlerts); err != nil { t.Fatalf("SaveAlertConfig: %v", err) } newSystem := config.SystemSettings{ PBSPollingInterval: 30, PMGPollingInterval: 30, AutoUpdateEnabled: true, DiscoveryEnabled: false, DiscoverySubnet: "10.20.0.0/24", DiscoveryConfig: config.DefaultDiscoveryConfig(), } if err := source.SaveSystemSettings(newSystem); err != nil { t.Fatalf("SaveSystemSettings: %v", err) } newTokens := []config.APITokenRecord{ { ID: "token-new", Name: "new", Hash: "hash-new", Prefix: "hashn", Suffix: "-n", CreatedAt: time.Date(2024, 2, 2, 12, 0, 0, 0, time.UTC), Scopes: []string{config.ScopeDockerReport}, }, } if err := source.SaveAPITokens(newTokens); err != nil { t.Fatalf("SaveAPITokens: %v", err) } exported, err := source.ExportConfig(passphrase) if err != nil { t.Fatalf("ExportConfig: %v", err) } targetDataDir := t.TempDir() t.Setenv("PULSE_DATA_DIR", targetDataDir) targetConfigDir := t.TempDir() target := config.NewConfigPersistence(targetConfigDir) if err := target.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } baselineNodes := []config.PVEInstance{ { Name: "pve-original", Host: "https://pve-original.example:8006", User: "root@pam", }, } if err := target.SaveNodesConfig(baselineNodes, nil, nil); err != nil { t.Fatalf("SaveNodesConfig baseline: %v", err) } baselineAlerts := alerts.AlertConfig{ Enabled: true, HysteresisMargin: 5, StorageDefault: alerts.HysteresisThreshold{ Trigger: 90, Clear: 85, }, Overrides: map[string]alerts.ThresholdConfig{}, } if err := target.SaveAlertConfig(baselineAlerts); err != nil { t.Fatalf("SaveAlertConfig baseline: %v", err) } baselineTokens := []config.APITokenRecord{ { ID: "token-original", Name: "original", Hash: "hash-original", Prefix: "hasho", Suffix: "-o", CreatedAt: time.Date(2023, 3, 3, 12, 0, 0, 0, time.UTC), Scopes: []string{config.ScopeWildcard}, }, } if err := target.SaveAPITokens(baselineTokens); err != nil { t.Fatalf("SaveAPITokens baseline: %v", err) } originalNodes, err := target.LoadNodesConfig() if err != nil { t.Fatalf("LoadNodesConfig: %v", err) } originalNodesJSON := mustMarshalJSON(t, originalNodes) originalAlerts, err := target.LoadAlertConfig() if err != nil { t.Fatalf("LoadAlertConfig: %v", err) } originalAlertsJSON := mustMarshalJSON(t, originalAlerts) originalTokens, err := target.LoadAPITokens() if err != nil { t.Fatalf("LoadAPITokens: %v", err) } originalTokensJSON := mustMarshalJSON(t, originalTokens) if err := os.Mkdir(filepath.Join(targetConfigDir, "system.json"), 0o700); err != nil { t.Fatalf("creating obstacle directory: %v", err) } if err := target.ImportConfig(exported, passphrase); err == nil { t.Fatal("expected import to fail, but it succeeded") } nodesAfter, err := target.LoadNodesConfig() if err != nil { t.Fatalf("LoadNodesConfig after failure: %v", err) } if !bytes.Equal(mustMarshalJSON(t, nodesAfter), originalNodesJSON) { t.Fatalf("nodes changed despite rollback:\noriginal: %s\ncurrent: %s", originalNodesJSON, mustMarshalJSON(t, nodesAfter)) } alertsAfter, err := target.LoadAlertConfig() if err != nil { t.Fatalf("LoadAlertConfig after failure: %v", err) } if !bytes.Equal(mustMarshalJSON(t, alertsAfter), originalAlertsJSON) { t.Fatalf("alerts changed despite rollback:\noriginal: %s\ncurrent: %s", originalAlertsJSON, mustMarshalJSON(t, alertsAfter)) } tokensAfter, err := target.LoadAPITokens() if err != nil { t.Fatalf("LoadAPITokens after failure: %v", err) } if !bytes.Equal(mustMarshalJSON(t, tokensAfter), originalTokensJSON) { t.Fatalf("api tokens changed despite rollback:\noriginal: %s\ncurrent: %s", originalTokensJSON, mustMarshalJSON(t, tokensAfter)) } tmpFiles, err := filepath.Glob(filepath.Join(targetConfigDir, "*.tmp")) if err != nil { t.Fatalf("Glob tmp files: %v", err) } if len(tmpFiles) != 0 { t.Fatalf("expected tmp files cleaned up after rollback, found %v", tmpFiles) } } func TestImportAcceptsVersion40Bundle(t *testing.T) { const passphrase = "import-legacy" sourceDataDir := t.TempDir() t.Setenv("PULSE_DATA_DIR", sourceDataDir) sourceConfigDir := t.TempDir() source := config.NewConfigPersistence(sourceConfigDir) if err := source.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } newNodes := []config.PVEInstance{ { Name: "pve-legacy", Host: "https://pve-legacy.example:8006", User: "root@pam", }, } if err := source.SaveNodesConfig(newNodes, nil, nil); err != nil { t.Fatalf("SaveNodesConfig: %v", err) } newAlerts := alerts.AlertConfig{ Enabled: true, HysteresisMargin: 4, StorageDefault: alerts.HysteresisThreshold{ Trigger: 75, Clear: 70, }, Overrides: map[string]alerts.ThresholdConfig{}, } if err := source.SaveAlertConfig(newAlerts); err != nil { t.Fatalf("SaveAlertConfig: %v", err) } newSystem := config.SystemSettings{ PBSPollingInterval: 80, PMGPollingInterval: 90, AutoUpdateEnabled: true, DiscoveryEnabled: true, DiscoverySubnet: "172.16.0.0/24", DiscoveryConfig: config.DefaultDiscoveryConfig(), } if err := source.SaveSystemSettings(newSystem); err != nil { t.Fatalf("SaveSystemSettings: %v", err) } exported, err := source.ExportConfig(passphrase) if err != nil { t.Fatalf("ExportConfig: %v", err) } exportData := mustDecodeExport(t, exported, passphrase) exportData.Version = "4.0" exportData.APITokens = nil legacyPayload := mustEncodeExport(t, exportData, passphrase) targetDataDir := t.TempDir() t.Setenv("PULSE_DATA_DIR", targetDataDir) targetConfigDir := t.TempDir() target := config.NewConfigPersistence(targetConfigDir) if err := target.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } baselineTokens := []config.APITokenRecord{ { ID: "token-legacy", Name: "keep-me", Hash: "hash-keep", Prefix: "hashk", Suffix: "-k", CreatedAt: time.Date(2022, 4, 4, 12, 0, 0, 0, time.UTC), Scopes: []string{config.ScopeWildcard}, }, } if err := target.SaveAPITokens(baselineTokens); err != nil { t.Fatalf("SaveAPITokens baseline: %v", err) } if err := target.ImportConfig(legacyPayload, passphrase); err != nil { t.Fatalf("ImportConfig (legacy 4.0): %v", err) } nodesAfter, err := target.LoadNodesConfig() if err != nil { t.Fatalf("LoadNodesConfig: %v", err) } assertJSONEqual(t, nodesAfter, exportData.Nodes, "nodes (4.0 import)") alertsAfter, err := target.LoadAlertConfig() if err != nil { t.Fatalf("LoadAlertConfig: %v", err) } assertJSONEqual(t, alertsAfter, exportData.Alerts, "alerts (4.0 import)") systemAfter, err := target.LoadSystemSettings() if err != nil { t.Fatalf("LoadSystemSettings: %v", err) } if systemAfter == nil { t.Fatal("expected system settings after legacy import") } assertJSONEqual(t, systemAfter, exportData.System, "system settings (4.0 import)") tokensAfter, err := target.LoadAPITokens() if err != nil { t.Fatalf("LoadAPITokens: %v", err) } assertJSONEqual(t, tokensAfter, baselineTokens, "api tokens unchanged for 4.0 import") } func TestLoadNodesConfigNormalizesPVEHostsAndClusterEndpoints(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } pveNodes := []config.PVEInstance{ { Name: "pve-cluster", Host: "https://pve.local", ClusterEndpoints: []config.ClusterEndpoint{ {NodeName: "pve1", Host: "https://pve1.local"}, {NodeName: "pve2", Host: "pve2.local"}, }, }, } if err := cp.SaveNodesConfig(pveNodes, nil, nil); err != nil { t.Fatalf("SaveNodesConfig: %v", err) } loaded, err := cp.LoadNodesConfig() if err != nil { t.Fatalf("LoadNodesConfig: %v", err) } if len(loaded.PVEInstances) != 1 { t.Fatalf("expected 1 PVE instance, got %d", len(loaded.PVEInstances)) } pve := loaded.PVEInstances[0] if pve.Host != "https://pve.local:8006" { t.Fatalf("expected primary host normalized with default port, got %q", pve.Host) } if len(pve.ClusterEndpoints) != 2 { t.Fatalf("expected 2 cluster endpoints, got %d", len(pve.ClusterEndpoints)) } if pve.ClusterEndpoints[0].Host != "https://pve1.local:8006" { t.Fatalf("expected endpoint host normalized, got %q", pve.ClusterEndpoints[0].Host) } if pve.ClusterEndpoints[1].Host != "https://pve2.local:8006" { t.Fatalf("expected endpoint host normalized, got %q", pve.ClusterEndpoints[1].Host) } // Second load should keep normalized values and not panic on migration. loadedAgain, err := cp.LoadNodesConfig() if err != nil { t.Fatalf("LoadNodesConfig second read: %v", err) } if loadedAgain.PVEInstances[0].Host != "https://pve.local:8006" { t.Fatalf("expected normalized host persisted, got %q", loadedAgain.PVEInstances[0].Host) } } func TestLoadNodesConfigConsolidatesStandalonePVEIntoClusterEndpoint(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } pveNodes := []config.PVEInstance{ { Name: "homelab", Host: "https://cluster-entry.local", ClusterName: "homelab", IsCluster: true, ClusterEndpoints: []config.ClusterEndpoint{ {NodeName: "minipc", Host: "10.0.0.5"}, }, }, { Name: "minipc-standalone", Host: "https://10.0.0.5", GuestURL: "https://minipc.example", Fingerprint: "fp-standalone", }, } if err := cp.SaveNodesConfig(pveNodes, nil, nil); err != nil { t.Fatalf("SaveNodesConfig: %v", err) } loaded, err := cp.LoadNodesConfig() if err != nil { t.Fatalf("LoadNodesConfig: %v", err) } if len(loaded.PVEInstances) != 1 { t.Fatalf("expected 1 consolidated PVE instance, got %d", len(loaded.PVEInstances)) } if len(loaded.PVEInstances[0].ClusterEndpoints) != 1 { t.Fatalf("expected 1 cluster endpoint, got %d", len(loaded.PVEInstances[0].ClusterEndpoints)) } if got := loaded.PVEInstances[0].ClusterEndpoints[0].GuestURL; got != "https://minipc.example" { t.Fatalf("GuestURL = %q, want https://minipc.example", got) } if got := loaded.PVEInstances[0].ClusterEndpoints[0].Fingerprint; got != "fp-standalone" { t.Fatalf("Fingerprint = %q, want fp-standalone", got) } } func mustDecodeExport(t *testing.T, payload, passphrase string) config.ExportData { t.Helper() raw, err := base64.StdEncoding.DecodeString(payload) if err != nil { t.Fatalf("base64 decode: %v", err) } plaintext, err := decryptExportPayload(raw, passphrase) if err != nil { t.Fatalf("decrypt export: %v", err) } var data config.ExportData if err := json.Unmarshal(plaintext, &data); err != nil { t.Fatalf("unmarshal export data: %v", err) } return data } func mustEncodeExport(t *testing.T, data config.ExportData, passphrase string) string { t.Helper() plaintext, err := json.Marshal(data) if err != nil { t.Fatalf("marshal export data: %v", err) } ciphertext, err := encryptExportPayload(plaintext, passphrase) if err != nil { t.Fatalf("encrypt export data: %v", err) } return base64.StdEncoding.EncodeToString(ciphertext) } func encryptExportPayload(plaintext []byte, passphrase string) ([]byte, error) { salt := make([]byte, 32) if _, err := io.ReadFull(rand.Reader, salt); err != nil { return nil, err } key := pbkdf2.Key([]byte(passphrase), salt, 600000, 32, sha256.New) block, err := aes.NewCipher(key) if err != nil { return nil, err } gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } nonce := make([]byte, gcm.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return nil, err } ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) result := append(salt, ciphertext...) return result, nil } func decryptExportPayload(ciphertext []byte, passphrase string) ([]byte, error) { if len(ciphertext) < 32 { return nil, io.ErrUnexpectedEOF } salt := ciphertext[:32] cipherbody := ciphertext[32:] key := pbkdf2.Key([]byte(passphrase), salt, 600000, 32, sha256.New) block, err := aes.NewCipher(key) if err != nil { return nil, err } gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } if len(cipherbody) < gcm.NonceSize() { return nil, io.ErrUnexpectedEOF } nonce := cipherbody[:gcm.NonceSize()] payload := cipherbody[gcm.NonceSize():] return gcm.Open(nil, nonce, payload, nil) } func mustMarshalJSON(t *testing.T, v interface{}) []byte { t.Helper() data, err := json.Marshal(v) if err != nil { t.Fatalf("marshal json: %v", err) } return data } func assertJSONEqual(t *testing.T, got interface{}, want interface{}, context string) { t.Helper() gotJSON := mustMarshalJSON(t, got) wantJSON := mustMarshalJSON(t, want) if !bytes.Equal(gotJSON, wantJSON) { t.Fatalf("%s mismatch:\n got: %s\nwant: %s", context, gotJSON, wantJSON) } } // ============================================================================ // Error path and edge case tests for persistence functions // ============================================================================ func TestLoadAPITokensErrorInvalidJSON(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Write invalid JSON to the api_tokens.json file tokensFile := filepath.Join(tempDir, "api_tokens.json") if err := os.WriteFile(tokensFile, []byte(`{invalid json content`), 0600); err != nil { t.Fatalf("WriteFile: %v", err) } _, err := cp.LoadAPITokens() if err == nil { t.Fatal("expected error for invalid JSON, got nil") } } func TestLoadAPITokensEmptyFile(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Write empty file tokensFile := filepath.Join(tempDir, "api_tokens.json") if err := os.WriteFile(tokensFile, []byte{}, 0600); err != nil { t.Fatalf("WriteFile: %v", err) } tokens, err := cp.LoadAPITokens() if err != nil { t.Fatalf("LoadAPITokens returned error for empty file: %v", err) } if len(tokens) != 0 { t.Fatalf("expected empty slice for empty file, got %d tokens", len(tokens)) } } func TestLoadAPITokensFileNotExist(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Don't create the file - test that non-existent file returns empty slice tokens, err := cp.LoadAPITokens() if err != nil { t.Fatalf("LoadAPITokens returned error for non-existent file: %v", err) } if len(tokens) != 0 { t.Fatalf("expected empty slice for non-existent file, got %d tokens", len(tokens)) } } func TestLoadAPITokens_MigratesPlaintextHashedFile(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } plaintextTokens := []config.APITokenRecord{ { Name: "Legacy Plaintext Metadata", Hash: "legacy-hash", Prefix: "leg", Suffix: "acy", CreatedAt: time.Now().UTC(), Scopes: []string{config.ScopeWildcard}, }, } plaintextData, err := json.Marshal(plaintextTokens) if err != nil { t.Fatalf("Marshal plaintext tokens: %v", err) } tokensFile := filepath.Join(tempDir, "api_tokens.json") if err := os.WriteFile(tokensFile, plaintextData, 0o600); err != nil { t.Fatalf("WriteFile: %v", err) } loaded, err := cp.LoadAPITokens() if err != nil { t.Fatalf("LoadAPITokens: %v", err) } if len(loaded) != 1 { t.Fatalf("loaded len = %d, want 1", len(loaded)) } if loaded[0].ID == "" { t.Fatalf("loaded token missing canonical ID after load") } rewritten, err := os.ReadFile(tokensFile) if err != nil { t.Fatalf("ReadFile rewritten api_tokens.json: %v", err) } if bytes.Equal(rewritten, plaintextData) { t.Fatalf("expected plaintext api token metadata file to be rewritten encrypted") } if bytes.Contains(rewritten, []byte(`"legacy-hash"`)) { t.Fatalf("rewritten api_tokens.json still exposes plaintext token metadata: %s", string(rewritten)) } reloaded, err := cp.LoadAPITokens() if err != nil { t.Fatalf("LoadAPITokens second read: %v", err) } if len(reloaded) != 1 { t.Fatalf("reloaded len = %d, want 1", len(reloaded)) } if reloaded[0].ID == "" { t.Fatalf("reloaded token missing canonical ID") } } func TestLoadAPITokens_CanonicalizesLegacyHostAgentScopeAliases(t *testing.T) { cp := config.NewConfigPersistence(t.TempDir()) legacy := []config.APITokenRecord{{ ID: "tok-1", Name: "legacy", Hash: "hashed-token", CreatedAt: time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC), Scopes: []string{"host-agent:report", "host-agent:config:read"}, OrgID: "default", }} if err := cp.SaveAPITokens(legacy); err != nil { t.Fatalf("SaveAPITokens: %v", err) } loaded, err := cp.LoadAPITokens() if err != nil { t.Fatalf("LoadAPITokens: %v", err) } if len(loaded) != 1 { t.Fatalf("expected 1 token, got %d", len(loaded)) } if got := loaded[0].Scopes; len(got) != 2 || got[0] != config.ScopeAgentReport || got[1] != config.ScopeAgentConfigRead { t.Fatalf("expected canonical agent scopes, got %#v", got) } } func TestLoadEmailConfigErrorInvalidJSON(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Write invalid JSON to the email.enc file (unencrypted for test without crypto) emailFile := filepath.Join(tempDir, "email.enc") if err := os.WriteFile(emailFile, []byte(`not valid json {{{{`), 0600); err != nil { t.Fatalf("WriteFile: %v", err) } _, err := cp.LoadEmailConfig() if err == nil { t.Fatal("expected error for invalid JSON/decryption failure, got nil") } } func TestLoadEmailConfigFileNotExist(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Don't create the file - test default config is returned cfg, err := cp.LoadEmailConfig() if err != nil { t.Fatalf("LoadEmailConfig returned error for non-existent file: %v", err) } if cfg == nil { t.Fatal("expected default config, got nil") } if cfg.Enabled { t.Fatal("expected Enabled=false for default config") } if cfg.SMTPPort != 587 { t.Fatalf("expected default SMTPPort=587, got %d", cfg.SMTPPort) } } func TestLoadEmailConfig_EncryptedRoundTrip(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Create a config with all fields populated original := notifications.EmailConfig{ Enabled: true, Provider: "smtp", SMTPHost: "mail.example.com", SMTPPort: 465, Username: "user@example.com", Password: "secret-password", From: "alerts@example.com", To: []string{"admin@example.com", "ops@example.com"}, TLS: true, StartTLS: false, } // Save (encrypts) then Load (decrypts) if err := cp.SaveEmailConfig(original); err != nil { t.Fatalf("SaveEmailConfig: %v", err) } loaded, err := cp.LoadEmailConfig() if err != nil { t.Fatalf("LoadEmailConfig: %v", err) } // Verify round-trip preserved all fields if loaded.Enabled != original.Enabled { t.Errorf("Enabled mismatch: got %v, want %v", loaded.Enabled, original.Enabled) } if loaded.Provider != original.Provider { t.Errorf("Provider mismatch: got %v, want %v", loaded.Provider, original.Provider) } if loaded.SMTPHost != original.SMTPHost { t.Errorf("SMTPHost mismatch: got %v, want %v", loaded.SMTPHost, original.SMTPHost) } if loaded.SMTPPort != original.SMTPPort { t.Errorf("SMTPPort mismatch: got %v, want %v", loaded.SMTPPort, original.SMTPPort) } if loaded.Username != original.Username { t.Errorf("Username mismatch: got %v, want %v", loaded.Username, original.Username) } if loaded.Password != original.Password { t.Errorf("Password mismatch: got %v, want %v", loaded.Password, original.Password) } if loaded.From != original.From { t.Errorf("From mismatch: got %v, want %v", loaded.From, original.From) } if len(loaded.To) != len(original.To) { t.Errorf("To length mismatch: got %d, want %d", len(loaded.To), len(original.To)) } if loaded.TLS != original.TLS { t.Errorf("TLS mismatch: got %v, want %v", loaded.TLS, original.TLS) } if loaded.StartTLS != original.StartTLS { t.Errorf("StartTLS mismatch: got %v, want %v", loaded.StartTLS, original.StartTLS) } } func TestLoadEmailConfig_MigratesPlaintextFile(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) emailFile := filepath.Join(tempDir, "email.enc") plaintext := notifications.EmailConfig{ Enabled: true, Provider: "smtp", SMTPHost: "mail.example.com", SMTPPort: 465, Username: "user@example.com", Password: "secret-password", From: "alerts@example.com", To: []string{"ops@example.com"}, } raw, err := json.Marshal(plaintext) if err != nil { t.Fatalf("Marshal plaintext email config: %v", err) } if err := os.WriteFile(emailFile, raw, 0o600); err != nil { t.Fatalf("WriteFile: %v", err) } loaded, err := cp.LoadEmailConfig() if err != nil { t.Fatalf("LoadEmailConfig: %v", err) } if loaded.SMTPHost != plaintext.SMTPHost { t.Fatalf("expected SMTPHost %q, got %q", plaintext.SMTPHost, loaded.SMTPHost) } rewritten, err := os.ReadFile(emailFile) if err != nil { t.Fatalf("ReadFile rewritten email config: %v", err) } if bytes.Equal(rewritten, raw) { t.Fatalf("expected plaintext email config file to be rewritten encrypted") } if bytes.Contains(rewritten, []byte("secret-password")) { t.Fatalf("rewritten email.enc still exposes plaintext credentials: %s", string(rewritten)) } } func TestLoadWebhooksErrorInvalidJSON(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Write invalid JSON to the webhooks.enc file webhooksFile := filepath.Join(tempDir, "webhooks.enc") if err := os.WriteFile(webhooksFile, []byte(`[{"broken`), 0600); err != nil { t.Fatalf("WriteFile: %v", err) } _, err := cp.LoadWebhooks() if err == nil { t.Fatal("expected error for invalid JSON/decryption failure, got nil") } } func TestLoadWebhooksFileNotExist(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Don't create the file - test empty slice is returned webhooks, err := cp.LoadWebhooks() if err != nil { t.Fatalf("LoadWebhooks returned error for non-existent file: %v", err) } if len(webhooks) != 0 { t.Fatalf("expected empty slice for non-existent file, got %d webhooks", len(webhooks)) } } func TestLoadWebhooksMigrationFromLegacyFile(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Create legacy webhooks.json file (unencrypted) legacyWebhooks := []notifications.WebhookConfig{ { ID: "webhook-1", Name: "test-webhook", URL: "https://example.com/hook", Method: "POST", Enabled: true, Service: "pushover", CustomFields: map[string]string{ "app_token": "legacy-app", "user_token": "legacy-user", }, }, } legacyData, err := json.Marshal(legacyWebhooks) if err != nil { t.Fatalf("Marshal: %v", err) } legacyFile := filepath.Join(tempDir, "webhooks.json") if err := os.WriteFile(legacyFile, legacyData, 0600); err != nil { t.Fatalf("WriteFile: %v", err) } // LoadWebhooks should find and parse the legacy file webhooks, err := cp.LoadWebhooks() if err != nil { t.Fatalf("LoadWebhooks returned error: %v", err) } if len(webhooks) != 1 { t.Fatalf("expected 1 webhook from legacy file, got %d", len(webhooks)) } if webhooks[0].ID != "webhook-1" { t.Fatalf("expected webhook ID 'webhook-1', got %q", webhooks[0].ID) } if got := webhooks[0].CustomFields["token"]; got != "legacy-app" { t.Fatalf("expected canonical token custom field after migration, got %q", got) } if got := webhooks[0].CustomFields["user"]; got != "legacy-user" { t.Fatalf("expected canonical user custom field after migration, got %q", got) } if _, ok := webhooks[0].CustomFields["app_token"]; ok { t.Fatalf("expected legacy app_token field to be removed after migration") } if _, ok := webhooks[0].CustomFields["user_token"]; ok { t.Fatalf("expected legacy user_token field to be removed after migration") } encryptedFile := filepath.Join(tempDir, "webhooks.enc") rewritten, err := os.ReadFile(encryptedFile) if err != nil { t.Fatalf("ReadFile rewritten webhooks: %v", err) } if bytes.Equal(rewritten, legacyData) { t.Fatalf("expected legacy webhooks file to be migrated into encrypted storage") } if _, err := os.Stat(legacyFile + ".backup"); err != nil { t.Fatalf("expected legacy webhooks backup after migration: %v", err) } } func TestLoadWebhooksMigrationFromUnencryptedEncFile(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Write plain JSON to webhooks.enc (migration scenario where file // was written before encryption was enabled) plainWebhooks := []notifications.WebhookConfig{ { ID: "unencrypted-webhook", Name: "plain-webhook", URL: "https://example.com/plain", Method: "POST", Enabled: true, Service: "pushover", CustomFields: map[string]string{ "app_token": "legacy-app", "user_token": "legacy-user", }, }, } plainData, err := json.Marshal(plainWebhooks) if err != nil { t.Fatalf("Marshal: %v", err) } encFile := filepath.Join(tempDir, "webhooks.enc") if err := os.WriteFile(encFile, plainData, 0600); err != nil { t.Fatalf("WriteFile: %v", err) } // LoadWebhooks should fall back to parsing as plain JSON when decryption fails webhooks, err := cp.LoadWebhooks() if err != nil { t.Fatalf("LoadWebhooks returned error: %v", err) } if len(webhooks) != 1 { t.Fatalf("expected 1 webhook, got %d", len(webhooks)) } if webhooks[0].ID != "unencrypted-webhook" { t.Fatalf("expected ID 'unencrypted-webhook', got %q", webhooks[0].ID) } if got := webhooks[0].CustomFields["token"]; got != "legacy-app" { t.Fatalf("expected canonical token custom field after migration, got %q", got) } if got := webhooks[0].CustomFields["user"]; got != "legacy-user" { t.Fatalf("expected canonical user custom field after migration, got %q", got) } if _, ok := webhooks[0].CustomFields["app_token"]; ok { t.Fatalf("expected legacy app_token field to be removed after migration") } if _, ok := webhooks[0].CustomFields["user_token"]; ok { t.Fatalf("expected legacy user_token field to be removed after migration") } rewritten, err := os.ReadFile(encFile) if err != nil { t.Fatalf("ReadFile rewritten webhooks.enc: %v", err) } if bytes.Equal(rewritten, plainData) { t.Fatalf("expected plaintext webhooks.enc to be rewritten encrypted") } } func TestLoadWebhooksLegacyFileInvalidJSON(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Create legacy file with invalid JSON - should be ignored, return empty legacyFile := filepath.Join(tempDir, "webhooks.json") if err := os.WriteFile(legacyFile, []byte(`{invalid json`), 0600); err != nil { t.Fatalf("WriteFile: %v", err) } // Should return empty slice since legacy file is invalid webhooks, err := cp.LoadWebhooks() if err != nil { t.Fatalf("LoadWebhooks returned error: %v", err) } if len(webhooks) != 0 { t.Fatalf("expected empty slice for invalid legacy file, got %d webhooks", len(webhooks)) } } func TestLoadNodesConfigEmptyArrays(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Use SaveNodesConfigAllowEmpty with empty slices to properly encrypt the data if err := cp.SaveNodesConfigAllowEmpty([]config.PVEInstance{}, []config.PBSInstance{}, []config.PMGInstance{}); err != nil { t.Fatalf("SaveNodesConfigAllowEmpty: %v", err) } cfg, err := cp.LoadNodesConfig() if err != nil { t.Fatalf("LoadNodesConfig returned error: %v", err) } if cfg == nil { t.Fatal("expected config, got nil") } if len(cfg.PVEInstances) != 0 { t.Fatalf("expected empty PVEInstances, got %d", len(cfg.PVEInstances)) } if len(cfg.PBSInstances) != 0 { t.Fatalf("expected empty PBSInstances, got %d", len(cfg.PBSInstances)) } if len(cfg.PMGInstances) != 0 { t.Fatalf("expected empty PMGInstances, got %d", len(cfg.PMGInstances)) } } func TestLoadNodesConfigMissingFields(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Save config with only PVE, no PBS and PMG // When we load it back, PBS and PMG should be initialized to empty slices pveInstances := []config.PVEInstance{ { Name: "pve-test", Host: "https://pve.local:8006", User: "root@pam", }, } // Save only PVE, pass nil for PBS and PMG if err := cp.SaveNodesConfig(pveInstances, nil, nil); err != nil { t.Fatalf("SaveNodesConfig: %v", err) } cfg, err := cp.LoadNodesConfig() if err != nil { t.Fatalf("LoadNodesConfig returned error: %v", err) } if cfg == nil { t.Fatal("expected config, got nil") } if len(cfg.PVEInstances) != 1 { t.Fatalf("expected 1 PVE instance, got %d", len(cfg.PVEInstances)) } // PBS and PMG should be initialized to empty slices if cfg.PBSInstances == nil { t.Fatal("expected PBSInstances to be initialized (not nil)") } if cfg.PMGInstances == nil { t.Fatal("expected PMGInstances to be initialized (not nil)") } } func TestLoadNodesConfigCorruptedRecoversWithEmptyConfig(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Write corrupted data (invalid encrypted content) // This tests the recovery behavior: when nodes.enc is corrupted and no backup exists, // LoadNodesConfig returns an empty config instead of an error to allow system startup nodesFile := filepath.Join(tempDir, "nodes.enc") if err := os.WriteFile(nodesFile, []byte(`{broken json`), 0600); err != nil { t.Fatalf("WriteFile: %v", err) } cfg, err := cp.LoadNodesConfig() if err != nil { t.Fatalf("LoadNodesConfig should recover gracefully from corruption, got error: %v", err) } // Verify we got an empty config (recovery behavior) if cfg == nil { t.Fatal("expected empty config on recovery, got nil") } if len(cfg.PVEInstances) != 0 { t.Fatalf("expected empty PVEInstances on recovery, got %d", len(cfg.PVEInstances)) } } func TestLoadNodesConfigFileNotExist(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Don't create the file - test that empty config is returned cfg, err := cp.LoadNodesConfig() if err != nil { t.Fatalf("LoadNodesConfig returned error for non-existent file: %v", err) } if cfg == nil { t.Fatal("expected empty config, got nil") } if len(cfg.PVEInstances) != 0 { t.Fatalf("expected empty PVEInstances, got %d", len(cfg.PVEInstances)) } } func TestLoadNodesConfig_PBSTokenClearing(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Save PBS instance with both Password and TokenName (buggy config) pbsInstances := []config.PBSInstance{ { Name: "pbs-buggy", Host: "https://pbs.local:8007", User: "root@pam", Password: "secret-password", TokenName: "should-be-cleared", TokenValue: "also-cleared", MonitorBackups: true, // Already set so no migration needed for this }, } if err := cp.SaveNodesConfig(nil, pbsInstances, nil); err != nil { t.Fatalf("SaveNodesConfig: %v", err) } loaded, err := cp.LoadNodesConfig() if err != nil { t.Fatalf("LoadNodesConfig: %v", err) } if len(loaded.PBSInstances) != 1 { t.Fatalf("expected 1 PBS instance, got %d", len(loaded.PBSInstances)) } pbs := loaded.PBSInstances[0] // TokenName/TokenValue should be cleared since Password is set if pbs.TokenName != "" { t.Errorf("expected TokenName cleared, got %q", pbs.TokenName) } if pbs.TokenValue != "" { t.Errorf("expected TokenValue cleared, got %q", pbs.TokenValue) } // Password should remain if pbs.Password != "secret-password" { t.Errorf("expected Password preserved, got %q", pbs.Password) } } func TestLoadNodesConfig_PBSHostNormalization(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Save PBS instance without port (should be normalized to :8007) pbsInstances := []config.PBSInstance{ { Name: "pbs-noport", Host: "https://pbs.local", User: "root@pam", Password: "pass", MonitorBackups: true, }, } if err := cp.SaveNodesConfig(nil, pbsInstances, nil); err != nil { t.Fatalf("SaveNodesConfig: %v", err) } loaded, err := cp.LoadNodesConfig() if err != nil { t.Fatalf("LoadNodesConfig: %v", err) } if len(loaded.PBSInstances) != 1 { t.Fatalf("expected 1 PBS instance, got %d", len(loaded.PBSInstances)) } pbs := loaded.PBSInstances[0] if pbs.Host != "https://pbs.local:8007" { t.Errorf("expected PBS host normalized with port, got %q", pbs.Host) } } func TestLoadNodesConfig_PMGTokenClearing(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Save PMG instance with both Password and TokenName (buggy config) pmgInstances := []config.PMGInstance{ { Name: "pmg-buggy", Host: "https://pmg.local:8006", User: "root@pam", Password: "secret-password", TokenName: "should-be-cleared", TokenValue: "also-cleared", }, } if err := cp.SaveNodesConfig(nil, nil, pmgInstances); err != nil { t.Fatalf("SaveNodesConfig: %v", err) } loaded, err := cp.LoadNodesConfig() if err != nil { t.Fatalf("LoadNodesConfig: %v", err) } if len(loaded.PMGInstances) != 1 { t.Fatalf("expected 1 PMG instance, got %d", len(loaded.PMGInstances)) } pmg := loaded.PMGInstances[0] // TokenName/TokenValue should be cleared since Password is set if pmg.TokenName != "" { t.Errorf("expected TokenName cleared, got %q", pmg.TokenName) } if pmg.TokenValue != "" { t.Errorf("expected TokenValue cleared, got %q", pmg.TokenValue) } // Password should remain if pmg.Password != "secret-password" { t.Errorf("expected Password preserved, got %q", pmg.Password) } } func TestLoadNodesConfig_PMGHostNormalization(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Save PMG instance without port (should be normalized to :8006) pmgInstances := []config.PMGInstance{ { Name: "pmg-noport", Host: "https://pmg.local", User: "root@pam", Password: "pass", }, } if err := cp.SaveNodesConfig(nil, nil, pmgInstances); err != nil { t.Fatalf("SaveNodesConfig: %v", err) } loaded, err := cp.LoadNodesConfig() if err != nil { t.Fatalf("LoadNodesConfig: %v", err) } if len(loaded.PMGInstances) != 1 { t.Fatalf("expected 1 PMG instance, got %d", len(loaded.PMGInstances)) } pmg := loaded.PMGInstances[0] if pmg.Host != "https://pmg.local:8006" { t.Errorf("expected PMG host normalized with port, got %q", pmg.Host) } } // NOTE: TestSaveNodesConfig_BlocksEmptyWhenNodesExist is not included because // the saveNodesConfig function has a deadlock bug - it holds c.mu.Lock() while // calling LoadNodesConfig() which tries to acquire c.mu.RLock(). This makes // the empty config protection path untestable without first fixing the bug. func TestSaveNodesConfigAllowEmpty_PermitsEmptyConfig(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // First save a valid config with nodes pveInstances := []config.PVEInstance{ { Name: "pve-test", Host: "https://pve.local:8006", User: "root@pam", }, } if err := cp.SaveNodesConfig(pveInstances, nil, nil); err != nil { t.Fatalf("SaveNodesConfig initial: %v", err) } // SaveNodesConfigAllowEmpty should permit deleting all nodes err := cp.SaveNodesConfigAllowEmpty(nil, nil, nil) if err != nil { t.Fatalf("SaveNodesConfigAllowEmpty should permit empty config, got: %v", err) } // Verify nodes are now empty loaded, err := cp.LoadNodesConfig() if err != nil { t.Fatalf("LoadNodesConfig: %v", err) } if len(loaded.PVEInstances) != 0 { t.Errorf("expected 0 PVE instances after AllowEmpty save, got %d", len(loaded.PVEInstances)) } } func TestCleanupOldBackupsNonExistentDirectory(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Save a valid config so we can trigger cleanup pveInstances := []config.PVEInstance{ { Name: "pve-test", Host: "https://pve.local:8006", User: "root@pam", }, } // First save to create the file if err := cp.SaveNodesConfig(pveInstances, nil, nil); err != nil { t.Fatalf("SaveNodesConfig: %v", err) } // Cleanup with non-existent pattern should not error // The pattern won't match anything, but it shouldn't panic or error // This is implicitly tested by the SaveNodesConfig which calls cleanupOldBackups // We just verify no panic occurred and the config was saved cfg, err := cp.LoadNodesConfig() if err != nil { t.Fatalf("LoadNodesConfig: %v", err) } if len(cfg.PVEInstances) != 1 { t.Fatalf("expected 1 PVE instance, got %d", len(cfg.PVEInstances)) } } func TestCleanupOldBackupsMultipleFiles(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } nodesFile := filepath.Join(tempDir, "nodes.enc") // Create initial file initialData := []byte(`{"pveInstances":[],"pbsInstances":[],"pmgInstances":[]}`) if err := os.WriteFile(nodesFile, initialData, 0600); err != nil { t.Fatalf("WriteFile: %v", err) } // Create 15 timestamped backup files (more than the 10 limit) baseTime := time.Now() for i := 0; i < 15; i++ { backupTime := baseTime.Add(time.Duration(-i) * time.Hour) backupFile := fmt.Sprintf("%s.backup-%s", nodesFile, backupTime.Format("20060102-150405")) content := fmt.Sprintf(`{"backup": %d}`, i) if err := os.WriteFile(backupFile, []byte(content), 0600); err != nil { t.Fatalf("WriteFile backup %d: %v", i, err) } // Set modification time to simulate different ages if err := os.Chtimes(backupFile, backupTime, backupTime); err != nil { t.Fatalf("Chtimes: %v", err) } } // Verify 15 backups exist matches, err := filepath.Glob(nodesFile + ".backup-*") if err != nil { t.Fatalf("Glob: %v", err) } if len(matches) != 15 { t.Fatalf("expected 15 backup files, got %d", len(matches)) } // Now save a new config, which should trigger cleanup pveInstances := []config.PVEInstance{ { Name: "pve-test", Host: "https://pve.local:8006", User: "root@pam", }, } if err := cp.SaveNodesConfig(pveInstances, nil, nil); err != nil { t.Fatalf("SaveNodesConfig: %v", err) } // Verify that old backups were cleaned up (should have at most 10 + 1 new = 11) // Actually the cleanup runs before the new backup is created, so we should have 10 old + 1 new = 11 matches, err = filepath.Glob(nodesFile + ".backup-*") if err != nil { t.Fatalf("Glob after cleanup: %v", err) } // After cleanup, we should have max 10 old backups + 1 new backup = 11 if len(matches) > 11 { t.Fatalf("expected at most 11 backup files after cleanup, got %d", len(matches)) } } func TestLoadAppriseConfigErrorInvalidJSON(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Write invalid JSON to the apprise.enc file appriseFile := filepath.Join(tempDir, "apprise.enc") if err := os.WriteFile(appriseFile, []byte(`{not valid}`), 0600); err != nil { t.Fatalf("WriteFile: %v", err) } _, err := cp.LoadAppriseConfig() if err == nil { t.Fatal("expected error for invalid JSON/decryption failure, got nil") } } func TestLoadAppriseConfigFileNotExist(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Don't create the file - test default config is returned cfg, err := cp.LoadAppriseConfig() if err != nil { t.Fatalf("LoadAppriseConfig returned error for non-existent file: %v", err) } if cfg == nil { t.Fatal("expected default config, got nil") } if cfg.Enabled { t.Fatal("expected Enabled=false for default config") } if cfg.TimeoutSeconds != 15 { t.Fatalf("expected default TimeoutSeconds=15, got %d", cfg.TimeoutSeconds) } } func TestLoadAppriseConfig_MigratesPlaintextFile(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) appriseFile := filepath.Join(tempDir, "apprise.enc") plaintext := notifications.AppriseConfig{ Enabled: true, Mode: notifications.AppriseModeHTTP, Targets: []string{"mailto://ops@example.com"}, ServerURL: "https://notify.example.com", APIKey: "secret-api-key", TimeoutSeconds: 30, } raw, err := json.Marshal(plaintext) if err != nil { t.Fatalf("Marshal plaintext apprise config: %v", err) } if err := os.WriteFile(appriseFile, raw, 0o600); err != nil { t.Fatalf("WriteFile: %v", err) } loaded, err := cp.LoadAppriseConfig() if err != nil { t.Fatalf("LoadAppriseConfig: %v", err) } if loaded.ServerURL != plaintext.ServerURL { t.Fatalf("expected ServerURL %q, got %q", plaintext.ServerURL, loaded.ServerURL) } rewritten, err := os.ReadFile(appriseFile) if err != nil { t.Fatalf("ReadFile rewritten apprise config: %v", err) } if bytes.Equal(rewritten, raw) { t.Fatalf("expected plaintext apprise config file to be rewritten encrypted") } if bytes.Contains(rewritten, []byte("secret-api-key")) { t.Fatalf("rewritten apprise.enc still exposes plaintext secret: %s", string(rewritten)) } } func TestLoadAlertConfigErrorInvalidJSON(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Write invalid JSON to the alerts.json file alertsFile := filepath.Join(tempDir, "alerts.json") if err := os.WriteFile(alertsFile, []byte(`{"broken": `), 0600); err != nil { t.Fatalf("WriteFile: %v", err) } _, err := cp.LoadAlertConfig() if err == nil { t.Fatal("expected error for invalid JSON, got nil") } } func TestLoadSystemSettingsErrorInvalidJSON(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Write invalid JSON to the system.json file systemFile := filepath.Join(tempDir, "system.json") if err := os.WriteFile(systemFile, []byte(`not json at all`), 0600); err != nil { t.Fatalf("WriteFile: %v", err) } _, err := cp.LoadSystemSettings() if err == nil { t.Fatal("expected error for invalid JSON, got nil") } } func TestLoadSystemSettingsFileNotExist(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Don't create the file - test nil is returned (env vars take precedence) settings, err := cp.LoadSystemSettings() if err != nil { t.Fatalf("LoadSystemSettings returned error for non-existent file: %v", err) } if settings != nil { t.Fatal("expected nil for non-existent system settings file") } } func TestLoadNodesConfig_PVEMonitorBackupsMigration(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Save PVE instance with MonitorBackups=false (simulating old config or missing field) pveInstances := []config.PVEInstance{ { Name: "pve-no-backups", Host: "https://pve.local:8006", User: "root@pam", Password: "secret", MonitorBackups: false, // This should be migrated to true }, } if err := cp.SaveNodesConfig(pveInstances, nil, nil); err != nil { t.Fatalf("SaveNodesConfig: %v", err) } loaded, err := cp.LoadNodesConfig() if err != nil { t.Fatalf("LoadNodesConfig: %v", err) } if len(loaded.PVEInstances) != 1 { t.Fatalf("expected 1 PVE instance, got %d", len(loaded.PVEInstances)) } pve := loaded.PVEInstances[0] // MonitorBackups should be migrated to true if !pve.MonitorBackups { t.Errorf("expected MonitorBackups to be migrated to true, got false") } } func TestLoadNodesConfig_PBSMonitorDatastoresMigration(t *testing.T) { tempDir := t.TempDir() cp := config.NewConfigPersistence(tempDir) if err := cp.EnsureConfigDir(); err != nil { t.Fatalf("EnsureConfigDir: %v", err) } // Save PBS instance with MonitorDatastores=false (simulating old config) pbsInstances := []config.PBSInstance{ { Name: "pbs-no-datastores", Host: "https://pbs.local:8007", User: "admin@pbs", Password: "secret", MonitorBackups: true, MonitorDatastores: false, // This should be migrated to true }, } if err := cp.SaveNodesConfig(nil, pbsInstances, nil); err != nil { t.Fatalf("SaveNodesConfig: %v", err) } loaded, err := cp.LoadNodesConfig() if err != nil { t.Fatalf("LoadNodesConfig: %v", err) } if len(loaded.PBSInstances) != 1 { t.Fatalf("expected 1 PBS instance, got %d", len(loaded.PBSInstances)) } pbs := loaded.PBSInstances[0] if !pbs.MonitorDatastores { t.Errorf("expected MonitorDatastores to be migrated to true, got false") } }