using EntKube.Clusters.Domain; using EntKube.Clusters.Features.AdoptCluster; using FluentAssertions; namespace EntKube.Clusters.Tests.Features; /// /// Tests for the component configuration schema system. Schemas define what /// parameters each component accepts, their types, validation constraints, /// and how they're grouped in the UI. This enables the frontend to render /// proper typed forms instead of raw key/value editors. /// public class ConfigurationSchemaTests { [Fact] public void ConfigurationParameter_WithRequiredString_ValidatesPresence() { // Arrange — A required parameter with no value provided. ConfigurationParameter param = new( Key: "email", DisplayName: "Email Address", Description: "The email registered with the certificate authority", Type: ParameterType.String, Group: "General", IsRequired: true); // Act ParameterValidationResult result = param.Validate(null); // Assert result.IsValid.Should().BeFalse(); result.Error.Should().Contain("required"); } [Fact] public void ConfigurationParameter_WithRequiredString_PassesWhenProvided() { // Arrange ConfigurationParameter param = new( Key: "email", DisplayName: "Email Address", Description: "The email registered with the certificate authority", Type: ParameterType.String, Group: "General", IsRequired: true); // Act ParameterValidationResult result = param.Validate("admin@example.com"); // Assert result.IsValid.Should().BeTrue(); } [Fact] public void ConfigurationParameter_WithIntegerType_RejectsNonNumeric() { // Arrange — An integer parameter with a non-numeric value. ConfigurationParameter param = new( Key: "replicas", DisplayName: "Replicas", Description: "Number of pod replicas", Type: ParameterType.Integer, Group: "Scaling", IsRequired: true, Min: 1, Max: 10); // Act ParameterValidationResult result = param.Validate("not-a-number"); // Assert result.IsValid.Should().BeFalse(); result.Error.Should().Contain("integer"); } [Fact] public void ConfigurationParameter_WithIntegerType_RejectsOutOfRange() { // Arrange — An integer outside the allowed range. ConfigurationParameter param = new( Key: "replicas", DisplayName: "Replicas", Description: "Number of pod replicas", Type: ParameterType.Integer, Group: "Scaling", IsRequired: true, Min: 1, Max: 10); // Act ParameterValidationResult result = param.Validate("15"); // Assert result.IsValid.Should().BeFalse(); result.Error.Should().Contain("10"); } [Fact] public void ConfigurationParameter_WithIntegerType_AcceptsValidValue() { // Arrange ConfigurationParameter param = new( Key: "replicas", DisplayName: "Replicas", Description: "Number of pod replicas", Type: ParameterType.Integer, Group: "Scaling", IsRequired: true, Min: 1, Max: 10); // Act ParameterValidationResult result = param.Validate("3"); // Assert result.IsValid.Should().BeTrue(); } [Fact] public void ConfigurationParameter_WithBooleanType_RejectsInvalidValues() { // Arrange — A boolean parameter with a non-boolean value. ConfigurationParameter param = new( Key: "grafanaEnabled", DisplayName: "Enable Grafana", Description: "Whether to deploy Grafana alongside Prometheus", Type: ParameterType.Boolean, Group: "Features"); // Act ParameterValidationResult result = param.Validate("maybe"); // Assert result.IsValid.Should().BeFalse(); result.Error.Should().Contain("true"); } [Fact] public void ConfigurationParameter_WithBooleanType_AcceptsTrueAndFalse() { // Arrange ConfigurationParameter param = new( Key: "grafanaEnabled", DisplayName: "Enable Grafana", Description: "Whether to deploy Grafana alongside Prometheus", Type: ParameterType.Boolean, Group: "Features"); // Act & Assert param.Validate("true").IsValid.Should().BeTrue(); param.Validate("false").IsValid.Should().BeTrue(); } [Fact] public void ConfigurationParameter_WithSelectType_RejectsUnknownOption() { // Arrange — A select parameter with allowed values. ConfigurationParameter param = new( Key: "provider", DisplayName: "Ingress Provider", Description: "Which ingress controller to use", Type: ParameterType.Select, Group: "General", IsRequired: true, AllowedValues: new List { "traefik", "istio" }); // Act ParameterValidationResult result = param.Validate("nginx"); // Assert result.IsValid.Should().BeFalse(); result.Error.Should().Contain("traefik"); } [Fact] public void ConfigurationParameter_WithSelectType_AcceptsAllowedValue() { // Arrange ConfigurationParameter param = new( Key: "provider", DisplayName: "Ingress Provider", Description: "Which ingress controller to use", Type: ParameterType.Select, Group: "General", IsRequired: true, AllowedValues: new List { "traefik", "istio" }); // Act ParameterValidationResult result = param.Validate("istio"); // Assert result.IsValid.Should().BeTrue(); } [Fact] public void ConfigurationParameter_Optional_AcceptsNullOrEmpty() { // Arrange — An optional parameter with no value is valid. ConfigurationParameter param = new( Key: "storageSize", DisplayName: "Storage Size", Description: "PVC storage size", Type: ParameterType.String, Group: "Storage", IsRequired: false, DefaultValue: "50Gi"); // Act & Assert param.Validate(null).IsValid.Should().BeTrue(); param.Validate("").IsValid.Should().BeTrue(); } [Fact] public void ConfigurationSchema_ValidateAll_ReturnsAllErrors() { // Arrange — A schema with multiple parameters, some invalid. ConfigurationSchema schema = new( ComponentName: "monitoring", DisplayName: "Monitoring (kube-prometheus-stack)", Description: "Prometheus, Grafana, and Alertmanager stack", Parameters: new List { new(Key: "retention", DisplayName: "Retention", Description: "Data retention period", Type: ParameterType.String, Group: "Storage", IsRequired: true), new(Key: "replicas", DisplayName: "Prometheus Replicas", Description: "Number of Prometheus replicas", Type: ParameterType.Integer, Group: "Scaling", IsRequired: true, Min: 1, Max: 10), new(Key: "grafanaEnabled", DisplayName: "Enable Grafana", Description: "Deploy Grafana", Type: ParameterType.Boolean, Group: "Features") }); Dictionary values = new() { ["replicas"] = "999", ["grafanaEnabled"] = "invalid" }; // Act SchemaValidationResult result = schema.Validate(values); // Assert — Three errors: retention missing, replicas out of range, grafana invalid. result.IsValid.Should().BeFalse(); result.Errors.Should().HaveCount(3); result.Errors.Should().ContainKey("retention"); result.Errors.Should().ContainKey("replicas"); result.Errors.Should().ContainKey("grafanaEnabled"); } [Fact] public void ConfigurationSchema_ValidateAll_PassesWithValidValues() { // Arrange ConfigurationSchema schema = new( ComponentName: "monitoring", DisplayName: "Monitoring (kube-prometheus-stack)", Description: "Prometheus, Grafana, and Alertmanager stack", Parameters: new List { new(Key: "retention", DisplayName: "Retention", Description: "Data retention period", Type: ParameterType.String, Group: "Storage", IsRequired: true), new(Key: "replicas", DisplayName: "Prometheus Replicas", Description: "Number of Prometheus replicas", Type: ParameterType.Integer, Group: "Scaling", IsRequired: true, Min: 1, Max: 10) }); Dictionary values = new() { ["retention"] = "30d", ["replicas"] = "2" }; // Act SchemaValidationResult result = schema.Validate(values); // Assert result.IsValid.Should().BeTrue(); result.Errors.Should().BeEmpty(); } [Fact] public void ConfigurationSchema_GroupsParameters_ByGroupName() { // Arrange — Schema parameters can be grouped for UI rendering. ConfigurationSchema schema = new( ComponentName: "monitoring", DisplayName: "Monitoring", Description: "Prometheus stack", Parameters: new List { new(Key: "retention", DisplayName: "Retention", Description: "Retention", Type: ParameterType.String, Group: "Storage"), new(Key: "storageSize", DisplayName: "Storage Size", Description: "PVC size", Type: ParameterType.String, Group: "Storage"), new(Key: "replicas", DisplayName: "Replicas", Description: "Replicas", Type: ParameterType.Integer, Group: "Scaling", Min: 1, Max: 10), new(Key: "grafanaEnabled", DisplayName: "Grafana", Description: "Enable Grafana", Type: ParameterType.Boolean, Group: "Features") }); // Act Dictionary> groups = schema.GetParametersByGroup(); // Assert groups.Should().HaveCount(3); groups["Storage"].Should().HaveCount(2); groups["Scaling"].Should().HaveCount(1); groups["Features"].Should().HaveCount(1); } [Fact] public void CertManagerSchema_DoesNotContainLetsEncryptParameters() { // Arrange & Act — The cert-manager schema should only have scaling // parameters. Let's Encrypt configuration belongs to the letsencrypt component. ConfigurationSchema schema = ComponentSchemas.GetSchema("cert-manager"); // Assert — No Let's Encrypt-related keys should exist. List paramKeys = schema.Parameters.Select(p => p.Key).ToList(); paramKeys.Should().NotContain("letsEncryptEmail"); paramKeys.Should().NotContain("httpSolverEnabled"); paramKeys.Should().NotContain("dnsSolverEnabled"); paramKeys.Should().NotContain("dnsSolverProvider"); paramKeys.Should().Contain("replicas"); } [Fact] public void LetsEncryptSchema_ContainsSolverAndEmailParameters() { // Arrange & Act — The letsencrypt schema should own all the ACME/solver // configuration that was previously in cert-manager. ConfigurationSchema schema = ComponentSchemas.GetSchema("letsencrypt"); // Assert List paramKeys = schema.Parameters.Select(p => p.Key).ToList(); paramKeys.Should().Contain("email"); paramKeys.Should().Contain("httpSolverEnabled"); paramKeys.Should().Contain("dnsSolverEnabled"); paramKeys.Should().Contain("dnsSolverProvider"); schema.Parameters.First(p => p.Key == "email").IsRequired.Should().BeTrue(); } [Fact] public void CertManagerSchema_OnlyHasScalingGroup() { // The cert-manager component is purely the runtime engine — its schema // should only expose scaling settings, not TLS/issuer configuration. ConfigurationSchema schema = ComponentSchemas.GetSchema("cert-manager"); Dictionary> groups = schema.GetParametersByGroup(); groups.Should().HaveCount(1); groups.Keys.Should().Contain("Scaling"); } // ─── Contextual Schema Enrichment ───────────────────────────────────── [Fact] public void EnrichForCluster_WithIstioIngress_DefaultsToGatewayHTTPRoute() { // Arrange — A cluster where the ingress component reports Istio as the // provider, with an internal gateway detected. When configuring Let's // Encrypt on this cluster, the HTTP-01 solver should default to // gatewayHTTPRoute mode with the internal gateway pre-selected. List components = new() { BuildComponent("ingress", ComponentStatus.Installed, new Dictionary { ["provider"] = "istio", ["hasInternalGateway"] = "True", ["hasExternalGateway"] = "True" }) }; // Act — Enrich the letsencrypt schema using the cluster's component state. ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("letsencrypt", components); // Assert — The HTTP-01 solver should default to Gateway API mode. ConfigurationParameter modeParam = enriched.Parameters.First(p => p.Key == "httpSolverMode"); modeParam.DefaultValue.Should().Be("gatewayHTTPRoute"); ConfigurationParameter gatewayNameParam = enriched.Parameters.First(p => p.Key == "httpSolverGatewayName"); gatewayNameParam.DefaultValue.Should().Be("internal"); ConfigurationParameter gatewayNsParam = enriched.Parameters.First(p => p.Key == "httpSolverGatewayNamespace"); gatewayNsParam.DefaultValue.Should().Be("internal-ingress"); } [Fact] public void EnrichForCluster_WithTraefikIngress_DefaultsToIngressMode() { // Arrange — A cluster using Traefik for ingress. The HTTP-01 solver // should default to ingress mode with "traefik" as the class. List components = new() { BuildComponent("ingress", ComponentStatus.Installed, new Dictionary { ["provider"] = "traefik", ["ingressClassRegistered"] = "True" }) }; // Act ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("letsencrypt", components); // Assert — Should remain in ingress mode with traefik class. ConfigurationParameter modeParam = enriched.Parameters.First(p => p.Key == "httpSolverMode"); modeParam.DefaultValue.Should().Be("ingress"); ConfigurationParameter classParam = enriched.Parameters.First(p => p.Key == "httpSolverIngressClass"); classParam.DefaultValue.Should().Be("traefik"); } [Fact] public void EnrichForCluster_WithNoIngressComponent_UsesStaticDefaults() { // Arrange — No ingress component detected on the cluster. The schema // should return unchanged static defaults. List components = new(); // Act ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("letsencrypt", components); // Assert — Static defaults should be preserved. ConfigurationParameter modeParam = enriched.Parameters.First(p => p.Key == "httpSolverMode"); modeParam.DefaultValue.Should().Be("ingress"); } [Fact] public void EnrichForCluster_WithIstioAndDiscoveredGateways_SetsGatewayAsSelect() { // Arrange — A cluster with Istio and discovered gateways stored in // the ingress component's config. The httpSolverGatewayName should // become a Select parameter with the available gateways as options. List components = new() { BuildComponent("ingress", ComponentStatus.Installed, new Dictionary { ["provider"] = "istio", ["hasInternalGateway"] = "True", ["hasExternalGateway"] = "True" }), BuildComponent("letsencrypt", ComponentStatus.Installed, new Dictionary { ["availableGateways"] = "[{\"name\":\"internal\",\"namespace\":\"internal-ingress\"},{\"name\":\"external\",\"namespace\":\"external-ingress\"}]" }) }; // Act ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("letsencrypt", components); // Assert — Gateway name should be a Select with discovered gateways as options. ConfigurationParameter gatewayParam = enriched.Parameters.First(p => p.Key == "httpSolverGatewayName"); gatewayParam.Type.Should().Be(ParameterType.Select); gatewayParam.AllowedValues.Should().Contain("internal"); gatewayParam.AllowedValues.Should().Contain("external"); } [Fact] public void EnrichForCluster_NonLetsEncryptComponent_ReturnsUnchanged() { // Arrange — Enriching a component other than letsencrypt should // return the static schema unchanged. List components = new() { BuildComponent("ingress", ComponentStatus.Installed, new Dictionary { ["provider"] = "istio" }) }; // Act ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("cert-manager", components); // Assert — cert-manager schema should be unchanged. ConfigurationSchema original = ComponentSchemas.GetSchema("cert-manager"); enriched.Parameters.Should().HaveCount(original.Parameters.Count); } [Fact] public void EnrichForCluster_WithIstioAndExistingLetsEncryptConfig_PreservesDiscoveredValues() { // Arrange — A cluster where Let's Encrypt was already configured with // specific values. The enrichment should set defaults from the ingress // provider but the existing discovered config (email, solver type) is // separate and handled by the UI pre-fill, not schema defaults. List components = new() { BuildComponent("ingress", ComponentStatus.Installed, new Dictionary { ["provider"] = "istio", ["hasInternalGateway"] = "True", ["hasExternalGateway"] = "False" }) }; // Act ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("letsencrypt", components); // Assert — With only internal gateway, external should not appear in options. // The gateway namespace should default to internal-ingress. ConfigurationParameter gatewayNsParam = enriched.Parameters.First(p => p.Key == "httpSolverGatewayNamespace"); gatewayNsParam.DefaultValue.Should().Be("internal-ingress"); } [Fact] public void EnrichForCluster_IngressWithExternalGateway_DefaultsEnableExternalToTrue() { // Arrange — The cluster scan detected an external gateway. When the // user opens the ingress configuration, the enable_external toggle // should default to true so it reflects reality. List components = new() { BuildComponent("ingress", ComponentStatus.Installed, new Dictionary { ["provider"] = "istio", ["hasExternalGateway"] = "True" }) }; // Act ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("ingress", components); // Assert — enable_external should default to "true". ConfigurationParameter extParam = enriched.Parameters.First(p => p.Key == "enable_external"); extParam.DefaultValue.Should().Be("true"); } [Fact] public void EnrichForCluster_IngressWithoutExternalGateway_DefaultsEnableExternalToFalse() { // Arrange — No external gateway detected. The default should stay false. List components = new() { BuildComponent("ingress", ComponentStatus.Installed, new Dictionary { ["provider"] = "istio", ["hasExternalGateway"] = "False" }) }; // Act ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("ingress", components); // Assert — enable_external should remain "false". ConfigurationParameter extParam = enriched.Parameters.First(p => p.Key == "enable_external"); extParam.DefaultValue.Should().Be("false"); } // ─── Helpers ────────────────────────────────────────────────────────── private static ClusterComponent BuildComponent( string name, ComponentStatus status, Dictionary config) { ComponentCheckResult checkResult = new( name, status, new List(), new List(), new DiscoveredConfiguration(null, null, null, config)); return ClusterComponent.FromCheckResult(checkResult); } [Fact] public void ParameterType_IncludesPostgresCluster() { // The UI needs a PostgresCluster parameter type to render a CNPG cluster // picker, similar to how StorageBucket renders a bucket picker. ParameterType type = ParameterType.PostgresCluster; type.Should().BeDefined(); } }