package config_test import ( "bytes" "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "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 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, TimeThreshold: 0, 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{ Temperature: &alerts.HysteresisThreshold{Trigger: 0, 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 loaded.TimeThreshold != 5 { t.Fatalf("expected time threshold default 5, got %d", loaded.TimeThreshold) } 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") } 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 != "/usr/local/bin/apprise" { t.Fatalf("expected CLI path to be trimmed, 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, }, { ID: "token-2", Name: "metrics", Hash: "hash-2", Prefix: "hash-2", Suffix: "-0002", CreatedAt: createdAt.Add(time.Hour), }, } if err := cp.SaveAPITokens(tokens); err != nil { t.Fatalf("SaveAPITokens: %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.1" { t.Fatalf("expected export version 4.1, got %q", decoded.Version) } assertJSONEqual(t, decoded.APITokens, tokens, "api tokens") } 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, }, TimeThreshold: 10, 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), }, } 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), }, } 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), }, } 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), }, } 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), }, } 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 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) } }