mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 17:19:57 +00:00
2452 lines
70 KiB
Go
2452 lines
70 KiB
Go
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, 100000, 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, 100000, 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")
|
|
}
|
|
}
|