package config import ( "os" "path/filepath" "strings" "testing" "time" ) // TestRestoreTestPBSRestoreTimeout mirrors the BackupCadence accessor contract: positive as-is, // 0 → default (120m), negative → default. func TestRestoreTestPBSRestoreTimeout(t *testing.T) { cases := []struct { secs int want time.Duration }{ {0, 120 * time.Minute}, {-5, 120 * time.Minute}, {1800, 30 * time.Minute}, {7200, 120 * time.Minute}, } for _, c := range cases { got := BackupConfig{RestoreTestPBSRestoreTimeoutSeconds: c.secs}.RestoreTestPBSRestoreTimeout() if got != c.want { t.Errorf("RestoreTestPBSRestoreTimeout(secs=%d) = %v, want %v", c.secs, got, c.want) } } } func TestRedactedMasksSecret(t *testing.T) { c := Default() c.Proxmox.Token = "felhom-agent@pve!agent=b6547d9d-08ec-4f22-beb8-a551dc2cd69d" got := c.Redacted().Proxmox.Token if strings.Contains(got, "b6547d9d") { t.Fatalf("secret leaked in redacted token: %q", got) } if !strings.HasPrefix(got, "felhom-agent@pve!agent=") { t.Errorf("redacted token lost its public prefix: %q", got) } // The original must be untouched (Redacted returns a copy). if !strings.Contains(c.Proxmox.Token, "b6547d9d") { t.Errorf("Redacted mutated the original config") } } func TestValidate(t *testing.T) { c := Default() c.Proxmox.Node = "demo-felhom" c.Proxmox.Token = "felhom-agent@pve!agent=secret" if err := c.Validate(); err != nil { t.Fatalf("valid config rejected: %v", err) } c.Proxmox.Token = "no-bang-no-eq" if err := c.Validate(); err == nil { t.Errorf("malformed token accepted") } } func TestRedactedMasksHubKey(t *testing.T) { c := Default() c.Hub.APIKey = "hub-secret-abcdef" if got := c.Redacted().Hub.APIKey; got == "hub-secret-abcdef" || got == "" { t.Fatalf("hub key not masked: %q", got) } if !strings.Contains(c.Hub.APIKey, "abcdef") { t.Error("Redacted mutated the original hub key") } } func TestHubConfigValidate(t *testing.T) { base := HubConfig{URL: "https://hub.felhom.eu", HostID: "h1", APIKey: "k"} if err := base.Validate(); err != nil { t.Fatalf("valid hub config rejected: %v", err) } bad := []HubConfig{ {HostID: "h", APIKey: "k"}, // no URL {URL: "https://x", APIKey: "k"}, // no host {URL: "https://x", HostID: "h"}, // no key {URL: "http://hub.felhom.eu", HostID: "h", APIKey: "k"}, // http non-loopback {URL: "ftp://x", HostID: "h", APIKey: "k"}, // bad scheme } for i, h := range bad { if err := h.Validate(); err == nil { t.Errorf("case %d: expected validation error for %+v", i, h) } } // http is allowed for loopback (tests). if err := (HubConfig{URL: "http://127.0.0.1:8443", HostID: "h", APIKey: "k"}).Validate(); err != nil { t.Errorf("http loopback should be allowed: %v", err) } } func TestHubEnvOverlayAndDefaults(t *testing.T) { t.Setenv("FELHOM_AGENT_HUB_URL", "https://hub.example") t.Setenv("FELHOM_AGENT_HUB_HOST_ID", "env-host") t.Setenv("FELHOM_AGENT_HUB_API_KEY", "env-key") t.Setenv("FELHOM_AGENT_HUB_POLL_SECONDS", "120") cfg, err := Load("") if err != nil { t.Fatal(err) } if cfg.Hub.URL != "https://hub.example" || cfg.Hub.HostID != "env-host" || cfg.Hub.APIKey != "env-key" { t.Errorf("hub env overlay failed: %+v", cfg.Hub) } if cfg.Hub.PollSeconds != 120 { t.Errorf("poll seconds = %d, want 120", cfg.Hub.PollSeconds) } // withDefaults fills zero timeout. if (HubConfig{}).WithDefaults().TimeoutSeconds != 30 { t.Error("WithDefaults should set TimeoutSeconds=30") } } func TestLoadFileThenEnvOverride(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "agent.json") if err := os.WriteFile(path, []byte(`{"proxmox":{"node":"file-node","token":"u@pve!t=filesecret"}}`), 0o600); err != nil { t.Fatal(err) } t.Setenv("FELHOM_AGENT_PROXMOX_NODE", "env-node") cfg, err := Load(path) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Proxmox.Node != "env-node" { t.Errorf("env did not override node: %q", cfg.Proxmox.Node) } if cfg.Proxmox.Token != "u@pve!t=filesecret" { t.Errorf("token from file lost: %q", cfg.Proxmox.Token) } if cfg.Proxmox.Endpoint != "https://127.0.0.1:8006" { t.Errorf("default endpoint lost: %q", cfg.Proxmox.Endpoint) } } // CAMPAIGN-3 Part 6: deployment_mode gates node self-heal, and it is FAIL-SAFE to byo — absent or any // unknown value is byo, ONLY the exact "appliance" unlocks the remedy. func TestIsAppliance_FailSafeToByo(t *testing.T) { cases := []struct { mode string want bool }{ {"appliance", true}, {"byo", false}, {"", false}, // absent field → byo (fail-safe) {"Appliance", false}, // case-sensitive — a typo must not unlock the remedy {"garbage", false}, } for _, c := range cases { cfg := &Config{DeploymentMode: c.mode} if got := cfg.IsAppliance(); got != c.want { t.Errorf("IsAppliance(mode=%q) = %t, want %t", c.mode, got, c.want) } } } // The env overlay can set deployment_mode (FELHOM_AGENT_DEPLOYMENT_MODE). func TestDeploymentModeEnvOverlay(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "agent.json") if err := os.WriteFile(path, []byte(`{"proxmox":{"node":"n","token":"u@pve!t=s"},"deployment_mode":"byo"}`), 0o600); err != nil { t.Fatal(err) } t.Setenv("FELHOM_AGENT_DEPLOYMENT_MODE", "appliance") cfg, err := Load(path) if err != nil { t.Fatalf("Load: %v", err) } if !cfg.IsAppliance() { t.Errorf("env overlay did not set deployment_mode: %q", cfg.DeploymentMode) } } // R-50: the island NIC fields are all-or-nothing and the guest addr must be a CIDR. A half-set or // malformed island must fail at config load (a botched install) rather than silently fall back to // LAN-only, which would leave a guest with an island bind and no island NIC — the exact silent break // R-50 exists to kill. Covers LocalAPIConfig.Validate + IslandEnabled. func TestLocalAPIConfig_IslandValidation(t *testing.T) { base := LocalAPIConfig{Enable: true, ListenAddr: "169.254.253.1:8443"} // both empty → fine (pre-R-50 default), IslandEnabled false if err := base.Validate(); err != nil { t.Errorf("no island config must validate: %v", err) } if base.IslandEnabled() { t.Errorf("IslandEnabled must be false when unset") } // both set, valid CIDR → fine, IslandEnabled true ok := base ok.IslandBridge, ok.IslandGuestAddr = "vmbr9", "169.254.253.2/30" if err := ok.Validate(); err != nil { t.Errorf("valid island config must validate: %v", err) } if !ok.IslandEnabled() { t.Errorf("IslandEnabled must be true when both set") } // bridge only → rejected (all-or-nothing) half := base half.IslandBridge = "vmbr9" if err := half.Validate(); err == nil { t.Errorf("half-set island (bridge only) must be rejected") } // guest addr only → rejected half2 := base half2.IslandGuestAddr = "169.254.253.2/30" if err := half2.Validate(); err == nil { t.Errorf("half-set island (guest addr only) must be rejected") } // both set but guest addr is not a CIDR → rejected bad := base bad.IslandBridge, bad.IslandGuestAddr = "vmbr9", "169.254.253.2" // missing /30 if err := bad.Validate(); err == nil { t.Errorf("island guest addr without a CIDR mask must be rejected") } }