From 0803951854e8b425642f57338d92340d5bd33279 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 1 Dec 2025 18:06:15 +0000 Subject: [PATCH] test: Add tests for getInstanceConfig, baseIntervalForInstanceType, LoadOIDCConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getInstanceConfig: 33%→100% (nil handling, case-insensitive matching) - baseIntervalForInstanceType: 50%→100% (all instance types, clamping) - LoadOIDCConfig: 35%→94% (file not exist, read errors, decryption) --- internal/config/persistence_test.go | 220 +++++++++++++++++++++++++++ internal/monitoring/monitor_test.go | 227 ++++++++++++++++++++++++++++ 2 files changed, 447 insertions(+) diff --git a/internal/config/persistence_test.go b/internal/config/persistence_test.go index ae5c72f..18bc90e 100644 --- a/internal/config/persistence_test.go +++ b/internal/config/persistence_test.go @@ -1487,3 +1487,223 @@ func TestLoadSystemSettingsFileNotExist(t *testing.T) { t.Fatal("expected nil for non-existent system settings file") } } + +// ============================================================================ +// LoadOIDCConfig error paths and success cases +// ============================================================================ + +func TestLoadOIDCConfigFileNotExist(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 nil, nil is returned + cfg, err := cp.LoadOIDCConfig() + if err != nil { + t.Fatalf("LoadOIDCConfig returned error for non-existent file: %v", err) + } + + if cfg != nil { + t.Fatal("expected nil config for non-existent file, got non-nil") + } +} + +func TestLoadOIDCConfigFileReadError(t *testing.T) { + tempDir := t.TempDir() + cp := config.NewConfigPersistence(tempDir) + if err := cp.EnsureConfigDir(); err != nil { + t.Fatalf("EnsureConfigDir: %v", err) + } + + // Create oidc.enc as a directory to trigger a read error (not IsNotExist) + oidcFile := filepath.Join(tempDir, "oidc.enc") + if err := os.Mkdir(oidcFile, 0700); err != nil { + t.Fatalf("Mkdir: %v", err) + } + + _, err := cp.LoadOIDCConfig() + if err == nil { + t.Fatal("expected error when reading directory as file, got nil") + } +} + +func TestLoadOIDCConfigValidJSONWithoutEncryption(t *testing.T) { + tempDir := t.TempDir() + cp := config.NewConfigPersistence(tempDir) + if err := cp.EnsureConfigDir(); err != nil { + t.Fatalf("EnsureConfigDir: %v", err) + } + + // Use SaveOIDCConfig to properly save (and encrypt) the config, + // then LoadOIDCConfig should be able to load it back + expected := config.OIDCConfig{ + Enabled: true, + IssuerURL: "https://auth.example.com", + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + RedirectURL: "https://app.example.com/callback", + Scopes: []string{"openid", "email", "profile"}, + UsernameClaim: "preferred_username", + } + + if err := cp.SaveOIDCConfig(expected); err != nil { + t.Fatalf("SaveOIDCConfig: %v", err) + } + + loaded, err := cp.LoadOIDCConfig() + if err != nil { + t.Fatalf("LoadOIDCConfig: %v", err) + } + + if loaded == nil { + t.Fatal("expected non-nil config, got nil") + } + if loaded.Enabled != expected.Enabled { + t.Fatalf("Enabled mismatch: got %v want %v", loaded.Enabled, expected.Enabled) + } + if loaded.IssuerURL != expected.IssuerURL { + t.Fatalf("IssuerURL mismatch: got %q want %q", loaded.IssuerURL, expected.IssuerURL) + } + if loaded.ClientID != expected.ClientID { + t.Fatalf("ClientID mismatch: got %q want %q", loaded.ClientID, expected.ClientID) + } + if loaded.ClientSecret != expected.ClientSecret { + t.Fatalf("ClientSecret mismatch: got %q want %q", loaded.ClientSecret, expected.ClientSecret) + } + if loaded.RedirectURL != expected.RedirectURL { + t.Fatalf("RedirectURL mismatch: got %q want %q", loaded.RedirectURL, expected.RedirectURL) + } + if loaded.UsernameClaim != expected.UsernameClaim { + t.Fatalf("UsernameClaim mismatch: got %q want %q", loaded.UsernameClaim, expected.UsernameClaim) + } + if !reflect.DeepEqual(loaded.Scopes, expected.Scopes) { + t.Fatalf("Scopes mismatch: got %v want %v", loaded.Scopes, expected.Scopes) + } +} + +func TestLoadOIDCConfigInvalidJSON(t *testing.T) { + tempDir := t.TempDir() + cp := config.NewConfigPersistence(tempDir) + if err := cp.EnsureConfigDir(); err != nil { + t.Fatalf("EnsureConfigDir: %v", err) + } + + // Write invalid data to oidc.enc file + // Since crypto is enabled, writing raw JSON will cause a decryption error first + // To test JSON unmarshal error, we need to bypass encryption or test the path + // where crypto is nil. Writing garbage data will trigger decrypt error which + // covers the decrypt error path. + oidcFile := filepath.Join(tempDir, "oidc.enc") + if err := os.WriteFile(oidcFile, []byte(`{invalid json content`), 0600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + _, err := cp.LoadOIDCConfig() + if err == nil { + t.Fatal("expected error for invalid data, got nil") + } +} + +func TestLoadOIDCConfigDecryptionError(t *testing.T) { + tempDir := t.TempDir() + cp := config.NewConfigPersistence(tempDir) + if err := cp.EnsureConfigDir(); err != nil { + t.Fatalf("EnsureConfigDir: %v", err) + } + + // Write data that will fail decryption (not valid encrypted format) + oidcFile := filepath.Join(tempDir, "oidc.enc") + if err := os.WriteFile(oidcFile, []byte(`corrupted encrypted data`), 0600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + _, err := cp.LoadOIDCConfig() + if err == nil { + t.Fatal("expected decryption error, got nil") + } +} + +func TestLoadOIDCConfigRoundTrip(t *testing.T) { + tempDir := t.TempDir() + cp := config.NewConfigPersistence(tempDir) + if err := cp.EnsureConfigDir(); err != nil { + t.Fatalf("EnsureConfigDir: %v", err) + } + + // Test full round-trip with encryption + original := config.OIDCConfig{ + Enabled: true, + IssuerURL: "https://idp.example.org/realms/myrealm", + ClientID: "my-app", + ClientSecret: "super-secret-value", + RedirectURL: "https://myapp.example.org/auth/callback", + LogoutURL: "https://idp.example.org/realms/myrealm/protocol/openid-connect/logout", + Scopes: []string{"openid", "email", "profile", "groups"}, + UsernameClaim: "preferred_username", + EmailClaim: "email", + GroupsClaim: "groups", + AllowedGroups: []string{"admin", "users"}, + AllowedDomains: []string{"example.org"}, + AllowedEmails: []string{"admin@example.org"}, + CABundle: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----", + } + + if err := cp.SaveOIDCConfig(original); err != nil { + t.Fatalf("SaveOIDCConfig: %v", err) + } + + loaded, err := cp.LoadOIDCConfig() + if err != nil { + t.Fatalf("LoadOIDCConfig: %v", err) + } + + if loaded == nil { + t.Fatal("expected config, got nil") + } + + // Verify all fields are preserved through the round-trip + if loaded.Enabled != original.Enabled { + t.Errorf("Enabled: got %v want %v", loaded.Enabled, original.Enabled) + } + if loaded.IssuerURL != original.IssuerURL { + t.Errorf("IssuerURL: got %q want %q", loaded.IssuerURL, original.IssuerURL) + } + if loaded.ClientID != original.ClientID { + t.Errorf("ClientID: got %q want %q", loaded.ClientID, original.ClientID) + } + if loaded.ClientSecret != original.ClientSecret { + t.Errorf("ClientSecret: got %q want %q", loaded.ClientSecret, original.ClientSecret) + } + if loaded.RedirectURL != original.RedirectURL { + t.Errorf("RedirectURL: got %q want %q", loaded.RedirectURL, original.RedirectURL) + } + if loaded.LogoutURL != original.LogoutURL { + t.Errorf("LogoutURL: got %q want %q", loaded.LogoutURL, original.LogoutURL) + } + if !reflect.DeepEqual(loaded.Scopes, original.Scopes) { + t.Errorf("Scopes: got %v want %v", loaded.Scopes, original.Scopes) + } + if loaded.UsernameClaim != original.UsernameClaim { + t.Errorf("UsernameClaim: got %q want %q", loaded.UsernameClaim, original.UsernameClaim) + } + if loaded.EmailClaim != original.EmailClaim { + t.Errorf("EmailClaim: got %q want %q", loaded.EmailClaim, original.EmailClaim) + } + if loaded.GroupsClaim != original.GroupsClaim { + t.Errorf("GroupsClaim: got %q want %q", loaded.GroupsClaim, original.GroupsClaim) + } + if !reflect.DeepEqual(loaded.AllowedGroups, original.AllowedGroups) { + t.Errorf("AllowedGroups: got %v want %v", loaded.AllowedGroups, original.AllowedGroups) + } + if !reflect.DeepEqual(loaded.AllowedDomains, original.AllowedDomains) { + t.Errorf("AllowedDomains: got %v want %v", loaded.AllowedDomains, original.AllowedDomains) + } + if !reflect.DeepEqual(loaded.AllowedEmails, original.AllowedEmails) { + t.Errorf("AllowedEmails: got %v want %v", loaded.AllowedEmails, original.AllowedEmails) + } + if loaded.CABundle != original.CABundle { + t.Errorf("CABundle: got %q want %q", loaded.CABundle, original.CABundle) + } +} diff --git a/internal/monitoring/monitor_test.go b/internal/monitoring/monitor_test.go index 327b63f..431d4d2 100644 --- a/internal/monitoring/monitor_test.go +++ b/internal/monitoring/monitor_test.go @@ -3,6 +3,8 @@ package monitoring import ( "testing" "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" ) func TestParseDurationEnv(t *testing.T) { @@ -162,3 +164,228 @@ func TestParseIntEnv(t *testing.T) { } }) } + +func TestGetInstanceConfig(t *testing.T) { + t.Run("nil Monitor returns nil", func(t *testing.T) { + var m *Monitor + result := m.getInstanceConfig("any") + if result != nil { + t.Errorf("expected nil, got %+v", result) + } + }) + + t.Run("nil config returns nil", func(t *testing.T) { + m := &Monitor{config: nil} + result := m.getInstanceConfig("any") + if result != nil { + t.Errorf("expected nil, got %+v", result) + } + }) + + t.Run("empty PVEInstances slice returns nil", func(t *testing.T) { + m := &Monitor{ + config: &config.Config{ + PVEInstances: []config.PVEInstance{}, + }, + } + result := m.getInstanceConfig("any") + if result != nil { + t.Errorf("expected nil, got %+v", result) + } + }) + + t.Run("instance found by exact name match", func(t *testing.T) { + m := &Monitor{ + config: &config.Config{ + PVEInstances: []config.PVEInstance{ + {Name: "pve1", Host: "192.168.1.1"}, + }, + }, + } + result := m.getInstanceConfig("pve1") + if result == nil { + t.Fatal("expected non-nil result") + } + if result.Name != "pve1" { + t.Errorf("expected Name 'pve1', got '%s'", result.Name) + } + if result.Host != "192.168.1.1" { + t.Errorf("expected Host '192.168.1.1', got '%s'", result.Host) + } + }) + + t.Run("instance found by case-insensitive match", func(t *testing.T) { + m := &Monitor{ + config: &config.Config{ + PVEInstances: []config.PVEInstance{ + {Name: "pve1", Host: "192.168.1.1"}, + }, + }, + } + result := m.getInstanceConfig("PVE1") + if result == nil { + t.Fatal("expected non-nil result") + } + if result.Name != "pve1" { + t.Errorf("expected Name 'pve1', got '%s'", result.Name) + } + }) + + t.Run("instance not found returns nil", func(t *testing.T) { + m := &Monitor{ + config: &config.Config{ + PVEInstances: []config.PVEInstance{ + {Name: "pve1", Host: "192.168.1.1"}, + }, + }, + } + result := m.getInstanceConfig("nonexistent") + if result != nil { + t.Errorf("expected nil, got %+v", result) + } + }) + + t.Run("multiple instances finds correct one", func(t *testing.T) { + m := &Monitor{ + config: &config.Config{ + PVEInstances: []config.PVEInstance{ + {Name: "pve1", Host: "192.168.1.1"}, + {Name: "pve2", Host: "192.168.1.2"}, + {Name: "pve3", Host: "192.168.1.3"}, + }, + }, + } + result := m.getInstanceConfig("pve2") + if result == nil { + t.Fatal("expected non-nil result") + } + if result.Name != "pve2" { + t.Errorf("expected Name 'pve2', got '%s'", result.Name) + } + if result.Host != "192.168.1.2" { + t.Errorf("expected Host '192.168.1.2', got '%s'", result.Host) + } + }) +} + +func TestBaseIntervalForInstanceType(t *testing.T) { + defaultInterval := DefaultSchedulerConfig().BaseInterval + + t.Run("nil Monitor returns default", func(t *testing.T) { + var m *Monitor + result := m.baseIntervalForInstanceType(InstanceTypePVE) + if result != defaultInterval { + t.Errorf("expected %v, got %v", defaultInterval, result) + } + }) + + t.Run("nil config returns default", func(t *testing.T) { + m := &Monitor{config: nil} + result := m.baseIntervalForInstanceType(InstanceTypePVE) + if result != defaultInterval { + t.Errorf("expected %v, got %v", defaultInterval, result) + } + }) + + t.Run("InstanceTypePVE returns effectivePVEPollingInterval result", func(t *testing.T) { + m := &Monitor{ + config: &config.Config{ + PVEPollingInterval: 30 * time.Second, + }, + } + result := m.baseIntervalForInstanceType(InstanceTypePVE) + expected := 30 * time.Second + if result != expected { + t.Errorf("expected %v, got %v", expected, result) + } + }) + + t.Run("InstanceTypePBS returns clamped PBS interval", func(t *testing.T) { + m := &Monitor{ + config: &config.Config{ + PBSPollingInterval: 45 * time.Second, + }, + } + result := m.baseIntervalForInstanceType(InstanceTypePBS) + expected := 45 * time.Second + if result != expected { + t.Errorf("expected %v, got %v", expected, result) + } + }) + + t.Run("InstanceTypePBS with interval < 10s gets clamped to 10s", func(t *testing.T) { + m := &Monitor{ + config: &config.Config{ + PBSPollingInterval: 5 * time.Second, + }, + } + result := m.baseIntervalForInstanceType(InstanceTypePBS) + expected := 10 * time.Second + if result != expected { + t.Errorf("expected %v, got %v", expected, result) + } + }) + + t.Run("InstanceTypePBS with interval > 1h gets clamped to 1h", func(t *testing.T) { + m := &Monitor{ + config: &config.Config{ + PBSPollingInterval: 2 * time.Hour, + }, + } + result := m.baseIntervalForInstanceType(InstanceTypePBS) + expected := time.Hour + if result != expected { + t.Errorf("expected %v, got %v", expected, result) + } + }) + + t.Run("InstanceTypePMG returns clamped PMG interval", func(t *testing.T) { + m := &Monitor{ + config: &config.Config{ + PMGPollingInterval: 2 * time.Minute, + }, + } + result := m.baseIntervalForInstanceType(InstanceTypePMG) + expected := 2 * time.Minute + if result != expected { + t.Errorf("expected %v, got %v", expected, result) + } + }) + + t.Run("unknown instance type with positive AdaptivePollingBaseInterval", func(t *testing.T) { + m := &Monitor{ + config: &config.Config{ + AdaptivePollingBaseInterval: 20 * time.Second, + }, + } + result := m.baseIntervalForInstanceType(InstanceType("unknown")) + expected := 20 * time.Second + if result != expected { + t.Errorf("expected %v, got %v", expected, result) + } + }) + + t.Run("unknown instance type with zero AdaptivePollingBaseInterval uses default", func(t *testing.T) { + m := &Monitor{ + config: &config.Config{ + AdaptivePollingBaseInterval: 0, + }, + } + result := m.baseIntervalForInstanceType(InstanceType("unknown")) + if result != defaultInterval { + t.Errorf("expected %v, got %v", defaultInterval, result) + } + }) + + t.Run("unknown instance type with negative AdaptivePollingBaseInterval uses default", func(t *testing.T) { + m := &Monitor{ + config: &config.Config{ + AdaptivePollingBaseInterval: -5 * time.Second, + }, + } + result := m.baseIntervalForInstanceType(InstanceType("unknown")) + if result != defaultInterval { + t.Errorf("expected %v, got %v", defaultInterval, result) + } + }) +}