using System.Text.Json; using EntKube.Clusters.Features.AdoptCluster.Components.LetsEncrypt; using FluentAssertions; namespace EntKube.Clusters.Tests.Features; /// /// Tests for the LetsEncrypt component check — specifically the DNS01 solver /// configuration extraction logic. When a cluster has ACME ClusterIssuers using /// DNS01 challenges, the check should extract the provider type, secret references, /// hosted zones, and domain selectors so the UI can display them properly. /// public class LetsEncryptCheckTests { // ─── DNS01 Solver Parsing ───────────────────────────────────────────── [Fact] public void ParseDns01Details_WithCloudflareSolver_ExtractsProviderAndSecret() { // Arrange — A ClusterIssuer with a Cloudflare DNS01 solver. The solver // specifies an API token stored in a Kubernetes Secret. string json = """ { "cloudflare": { "apiTokenSecretRef": { "name": "cloudflare-api-token", "key": "api-token" } } } """; JsonElement dns01Element = JsonDocument.Parse(json).RootElement; // Act — Extract DNS01 details from the solver configuration. Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element); // Assert — Should identify Cloudflare as the provider and capture the secret name. details.Provider.Should().Be("cloudflare"); details.SecretName.Should().Be("cloudflare-api-token"); details.HostedZone.Should().BeNull(); details.Project.Should().BeNull(); } [Fact] public void ParseDns01Details_WithRoute53Solver_ExtractsProviderAndHostedZone() { // Arrange — A ClusterIssuer with an AWS Route53 DNS01 solver. Route53 // solvers include a hosted zone ID and region. string json = """ { "route53": { "region": "eu-north-1", "hostedZoneID": "Z1234567890ABC" } } """; JsonElement dns01Element = JsonDocument.Parse(json).RootElement; // Act Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element); // Assert details.Provider.Should().Be("route53"); details.HostedZone.Should().Be("Z1234567890ABC"); details.SecretName.Should().BeNull(); details.Project.Should().BeNull(); } [Fact] public void ParseDns01Details_WithRoute53AndSecretAccessKey_ExtractsSecretName() { // Arrange — A Route53 DNS01 solver using explicit credentials stored in // a Kubernetes Secret via secretAccessKeySecretRef. This is the most common // setup for non-IRSA (IAM Roles for Service Accounts) Route53 configurations. string json = """ { "route53": { "region": "eu-north-1", "hostedZoneID": "Z1234567890ABC", "accessKeyID": "AKIAIOSFODNN7EXAMPLE", "secretAccessKeySecretRef": { "name": "aws-route53-credentials", "key": "secret-access-key" } } } """; JsonElement dns01Element = JsonDocument.Parse(json).RootElement; // Act Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element); // Assert — Should capture both the hosted zone and the credential secret. details.Provider.Should().Be("route53"); details.HostedZone.Should().Be("Z1234567890ABC"); details.SecretName.Should().Be("aws-route53-credentials"); details.Project.Should().BeNull(); } [Fact] public void ParseDns01Details_WithAzureDnsServicePrincipal_ExtractsAllFields() { // Arrange — An Azure DNS solver using a service principal for authentication. // This is the most common Azure DNS setup: the ClusterIssuer authenticates // to Azure using a clientID + clientSecret (stored in a K8s Secret), // scoped to a specific subscription, tenant, resource group, and hosted zone. string json = """ { "azureDNS": { "clientID": "app-id-12345", "clientSecretSecretRef": { "name": "azuredns-sp-secret", "key": "client-secret" }, "subscriptionID": "sub-aaaa-bbbb-cccc", "tenantID": "tenant-xxxx-yyyy", "resourceGroupName": "dns-rg", "hostedZoneName": "example.com", "environment": "AzurePublicCloud" } } """; JsonElement dns01Element = JsonDocument.Parse(json).RootElement; // Act Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element); // Assert — All Azure DNS fields should be extracted. details.Provider.Should().Be("azuredns"); details.HostedZone.Should().Be("example.com"); details.SecretName.Should().Be("azuredns-sp-secret"); details.ClientId.Should().Be("app-id-12345"); details.SubscriptionId.Should().Be("sub-aaaa-bbbb-cccc"); details.TenantId.Should().Be("tenant-xxxx-yyyy"); details.ResourceGroup.Should().Be("dns-rg"); details.Project.Should().BeNull(); } [Fact] public void ParseDns01Details_WithAzureDnsManagedIdentity_ExtractsHostedZone() { // Arrange — An Azure DNS solver using managed identity (no clientSecret). // Only the hosted zone and resource group are present. string json = """ { "azureDNS": { "hostedZoneName": "example.com", "resourceGroupName": "dns-rg", "subscriptionID": "sub-123", "environment": "AzurePublicCloud" } } """; JsonElement dns01Element = JsonDocument.Parse(json).RootElement; // Act Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element); // Assert — Should capture what's available, leave missing fields null. details.Provider.Should().Be("azuredns"); details.HostedZone.Should().Be("example.com"); details.ResourceGroup.Should().Be("dns-rg"); details.SubscriptionId.Should().Be("sub-123"); details.SecretName.Should().BeNull(); details.ClientId.Should().BeNull(); details.TenantId.Should().BeNull(); } [Fact] public void ParseDns01Details_WithCloudDnsSolver_ExtractsProviderAndProject() { // Arrange — A Google Cloud DNS solver with a project and service account. string json = """ { "cloudDNS": { "project": "my-gcp-project", "serviceAccountSecretRef": { "name": "clouddns-service-account", "key": "key.json" } } } """; JsonElement dns01Element = JsonDocument.Parse(json).RootElement; // Act Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element); // Assert details.Provider.Should().Be("clouddns"); details.Project.Should().Be("my-gcp-project"); details.SecretName.Should().Be("clouddns-service-account"); details.HostedZone.Should().BeNull(); } [Fact] public void ParseDns01Details_WithEmptyDns01_ReturnsUnknownProvider() { // Arrange — A DNS01 solver with no recognized provider (unusual but possible // with custom webhook solvers). string json = """ { "webhook": { "solverName": "custom-solver" } } """; JsonElement dns01Element = JsonDocument.Parse(json).RootElement; // Act Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element); // Assert — Should report unknown when no recognized provider is found. details.Provider.Should().BeNull(); details.SecretName.Should().BeNull(); } // ─── Domain Selector Parsing ────────────────────────────────────────── [Fact] public void ParseDnsZones_WithDnsZonesSelector_ExtractsZones() { // Arrange — A solver with a selector that scopes it to specific DNS zones. // This is common when using DNS01 for wildcard certificates on specific domains. string json = """ { "selector": { "dnsZones": ["example.com", "internal.example.com"] }, "dns01": { "cloudflare": { "apiTokenSecretRef": { "name": "cf-token", "key": "api-token" } } } } """; JsonElement solverElement = JsonDocument.Parse(json).RootElement; // Act List zones = LetsEncryptCheck.ParseDnsZones(solverElement); // Assert zones.Should().BeEquivalentTo(new[] { "example.com", "internal.example.com" }); } [Fact] public void ParseDnsZones_WithNoSelector_ReturnsEmptyList() { // Arrange — A solver with no selector (applies to all domains). string json = """ { "dns01": { "cloudflare": { "apiTokenSecretRef": { "name": "cf-token", "key": "api-token" } } } } """; JsonElement solverElement = JsonDocument.Parse(json).RootElement; // Act List zones = LetsEncryptCheck.ParseDnsZones(solverElement); // Assert zones.Should().BeEmpty(); } // ─── Per-Issuer Config Values ───────────────────────────────────────── [Fact] public void BuildConfigValues_WithMultipleIssuers_IncludesPerIssuerDetails() { // Arrange — Two ACME issuers: one staging with HTTP01, one production // with DNS01 using Cloudflare. The config values should include both // global summary and per-issuer detail so the UI can display each issuer. List issuers = new() { new DetectedIssuer( Name: "letsencrypt-staging", Email: "admin@example.com", AcmeServer: "https://acme-staging-v02.api.letsencrypt.org/directory", SolverType: "HTTP01", IsReady: true, Dns01Details: null, DnsZones: new List()), new DetectedIssuer( Name: "letsencrypt-prod", Email: "admin@example.com", AcmeServer: "https://acme-v02.api.letsencrypt.org/directory", SolverType: "DNS01", IsReady: true, Dns01Details: new Dns01Details( Provider: "cloudflare", SecretName: "cloudflare-api-token", HostedZone: null, Project: null), DnsZones: new List { "example.com" }) }; // Act — Build the configuration values dictionary from the detected issuers. Dictionary configValues = LetsEncryptCheck.BuildConfigValues(issuers); // Assert — Global summary values. configValues["issuers"].Should().Be("letsencrypt-staging,letsencrypt-prod"); configValues["issuerCount"].Should().Be("2"); configValues["email"].Should().Be("admin@example.com"); configValues["readyCount"].Should().Be("2"); configValues["httpSolverEnabled"].Should().Be("true"); configValues["dnsSolverEnabled"].Should().Be("true"); // Assert — Per-issuer detail for the DNS01 issuer. configValues["issuer.letsencrypt-prod.solverType"].Should().Be("DNS01"); configValues["issuer.letsencrypt-prod.acmeServer"].Should().Be("https://acme-v02.api.letsencrypt.org/directory"); configValues["issuer.letsencrypt-prod.dnsSolverProvider"].Should().Be("cloudflare"); configValues["issuer.letsencrypt-prod.dnsSolverSecretName"].Should().Be("cloudflare-api-token"); configValues["issuer.letsencrypt-prod.dnsZones"].Should().Be("example.com"); // Assert — Per-issuer detail for the HTTP01 issuer. configValues["issuer.letsencrypt-staging.solverType"].Should().Be("HTTP01"); configValues["issuer.letsencrypt-staging.acmeServer"].Should().Be("https://acme-staging-v02.api.letsencrypt.org/directory"); } [Fact] public void BuildConfigValues_WithDns01Solver_PopulatesGlobalDnsSolverFields() { // Arrange — A single issuer with DNS01. The global config values should // include the DNS solver details so the "Current Configuration" view // shows them without needing to expand per-issuer details. List issuers = new() { new DetectedIssuer( Name: "letsencrypt-production", Email: "certs@company.com", AcmeServer: "https://acme-v02.api.letsencrypt.org/directory", SolverType: "DNS01", IsReady: true, Dns01Details: new Dns01Details( Provider: "route53", SecretName: null, HostedZone: "Z1234567890ABC", Project: null), DnsZones: new List { "company.com", "internal.company.com" }) }; // Act Dictionary configValues = LetsEncryptCheck.BuildConfigValues(issuers); // Assert — Global DNS solver values populated from the first DNS01 issuer found. configValues["httpSolverEnabled"].Should().Be("false"); configValues["dnsSolverEnabled"].Should().Be("true"); configValues["dnsSolverProvider"].Should().Be("route53"); configValues["dnsSolverHostedZone"].Should().Be("Z1234567890ABC"); configValues["dnsZones"].Should().Be("company.com,internal.company.com"); configValues.Should().NotContainKey("dnsSolverProject"); } [Fact] public void BuildConfigValues_WithCloudDnsSolver_IncludesProject() { // Arrange — A GCP Cloud DNS solver should include the project. List issuers = new() { new DetectedIssuer( Name: "letsencrypt-prod", Email: "ops@company.com", AcmeServer: "https://acme-v02.api.letsencrypt.org/directory", SolverType: "DNS01", IsReady: true, Dns01Details: new Dns01Details( Provider: "clouddns", SecretName: "gcp-dns-sa", HostedZone: null, Project: "my-gcp-project"), DnsZones: new List()) }; // Act Dictionary configValues = LetsEncryptCheck.BuildConfigValues(issuers); // Assert configValues["dnsSolverProvider"].Should().Be("clouddns"); configValues["dnsSolverProject"].Should().Be("my-gcp-project"); configValues["dnsSolverSecretName"].Should().Be("gcp-dns-sa"); configValues["dnsSolverSecretNameGcp"].Should().Be("gcp-dns-sa"); } [Fact] public void BuildConfigValues_WithHttp01Only_DoesNotIncludeDnsFields() { // Arrange — An HTTP01-only issuer should not produce DNS solver fields. List issuers = new() { new DetectedIssuer( Name: "letsencrypt-prod", Email: "admin@example.com", AcmeServer: "https://acme-v02.api.letsencrypt.org/directory", SolverType: "HTTP01", IsReady: true, Dns01Details: null, DnsZones: new List()) }; // Act Dictionary configValues = LetsEncryptCheck.BuildConfigValues(issuers); // Assert configValues["httpSolverEnabled"].Should().Be("true"); configValues["dnsSolverEnabled"].Should().Be("false"); configValues.Should().NotContainKey("dnsSolverProvider"); configValues.Should().NotContainKey("dnsSolverSecretName"); configValues.Should().NotContainKey("dnsSolverHostedZone"); configValues.Should().NotContainKey("dnsSolverProject"); configValues.Should().NotContainKey("dnsSolverClientId"); configValues.Should().NotContainKey("dnsSolverSubscriptionId"); configValues.Should().NotContainKey("dnsSolverTenantId"); configValues.Should().NotContainKey("dnsSolverResourceGroup"); configValues.Should().NotContainKey("dnsZones"); } [Fact] public void BuildConfigValues_WithAzureDnsServicePrincipal_IncludesAllAzureFields() { // Arrange — An Azure DNS solver with full service principal configuration. // All Azure-specific fields should appear in both global and per-issuer config. List issuers = new() { new DetectedIssuer( Name: "letsencrypt-prod", Email: "certs@company.com", AcmeServer: "https://acme-v02.api.letsencrypt.org/directory", SolverType: "DNS01", IsReady: true, Dns01Details: new Dns01Details( Provider: "azuredns", SecretName: "azuredns-sp-secret", HostedZone: "company.com", Project: null, ClientId: "app-id-12345", SubscriptionId: "sub-aaaa-bbbb", TenantId: "tenant-xxxx", ResourceGroup: "dns-rg"), DnsZones: new List()) }; // Act Dictionary configValues = LetsEncryptCheck.BuildConfigValues(issuers); // Assert — Global DNS solver values include all Azure fields. configValues["dnsSolverProvider"].Should().Be("azuredns"); configValues["dnsSolverSecretName"].Should().Be("azuredns-sp-secret"); configValues["dnsSolverSecretNameAzure"].Should().Be("azuredns-sp-secret"); configValues["dnsSolverHostedZone"].Should().Be("company.com"); configValues["dnsSolverClientId"].Should().Be("app-id-12345"); configValues["dnsSolverSubscriptionId"].Should().Be("sub-aaaa-bbbb"); configValues["dnsSolverTenantId"].Should().Be("tenant-xxxx"); configValues["dnsSolverResourceGroup"].Should().Be("dns-rg"); // Assert — Per-issuer values also include Azure fields. configValues["issuer.letsencrypt-prod.dnsSolverProvider"].Should().Be("azuredns"); configValues["issuer.letsencrypt-prod.dnsSolverSecretName"].Should().Be("azuredns-sp-secret"); configValues["issuer.letsencrypt-prod.dnsSolverClientId"].Should().Be("app-id-12345"); configValues["issuer.letsencrypt-prod.dnsSolverSubscriptionId"].Should().Be("sub-aaaa-bbbb"); configValues["issuer.letsencrypt-prod.dnsSolverTenantId"].Should().Be("tenant-xxxx"); configValues["issuer.letsencrypt-prod.dnsSolverResourceGroup"].Should().Be("dns-rg"); } // ─── HTTP01 Solver Mode Detection ───────────────────────────────────── [Fact] public void BuildConfigValues_WithGatewayApiHttp01_ReportsGatewayMode() { // Arrange — An issuer using HTTP01 via gatewayHTTPRoute (Gateway API) // instead of traditional Ingress. The config should report the mode // so the UI can show Gateway API-specific fields. List issuers = new() { new DetectedIssuer( Name: "letsencrypt-prod", Email: "admin@example.com", AcmeServer: "https://acme-v02.api.letsencrypt.org/directory", SolverType: "HTTP01", IsReady: true, Dns01Details: null, DnsZones: new List(), Http01Mode: "gatewayHTTPRoute", Http01GatewayName: "internal", Http01GatewayNamespace: "internal-ingress") }; // Act Dictionary configValues = LetsEncryptCheck.BuildConfigValues(issuers); // Assert — Global config should reflect gateway mode. configValues["httpSolverEnabled"].Should().Be("true"); configValues["httpSolverMode"].Should().Be("gatewayHTTPRoute"); configValues["httpSolverGatewayName"].Should().Be("internal"); configValues["httpSolverGatewayNamespace"].Should().Be("internal-ingress"); configValues.Should().NotContainKey("httpSolverIngressClass"); } [Fact] public void BuildConfigValues_WithIngressHttp01_ReportsIngressMode() { // Arrange — A traditional ingress-based HTTP01 solver. The config // should report "ingress" mode with the ingress class. List issuers = new() { new DetectedIssuer( Name: "letsencrypt-prod", Email: "admin@example.com", AcmeServer: "https://acme-v02.api.letsencrypt.org/directory", SolverType: "HTTP01", IsReady: true, Dns01Details: null, DnsZones: new List(), Http01Mode: "ingress", Http01IngressClass: "traefik") }; // Act Dictionary configValues = LetsEncryptCheck.BuildConfigValues(issuers); // Assert — Global config should reflect ingress mode. configValues["httpSolverEnabled"].Should().Be("true"); configValues["httpSolverMode"].Should().Be("ingress"); configValues["httpSolverIngressClass"].Should().Be("traefik"); configValues.Should().NotContainKey("httpSolverGatewayName"); } // ─── HTTP01 Solver Parsing ──────────────────────────────────────────── [Fact] public void ParseHttp01Details_WithGatewayHTTPRoute_ExtractsParentRef() { // Arrange — An HTTP01 solver configured with gatewayHTTPRoute (for // clusters using Gateway API instead of Ingress). The solver specifies // which Gateway to attach the challenge HTTPRoute to. string json = """ { "http01": { "gatewayHTTPRoute": { "parentRefs": [ { "name": "internal", "namespace": "internal-ingress", "kind": "Gateway" } ] } } } """; JsonElement solverElement = JsonDocument.Parse(json).RootElement; // Act Http01Details details = LetsEncryptCheck.ParseHttp01Details(solverElement); // Assert — Should identify gateway mode and extract parent ref. details.Mode.Should().Be("gatewayHTTPRoute"); details.GatewayName.Should().Be("internal"); details.GatewayNamespace.Should().Be("internal-ingress"); details.IngressClass.Should().BeNull(); } [Fact] public void ParseHttp01Details_WithIngress_ExtractsIngressClass() { // Arrange — A traditional ingress-based HTTP01 solver. string json = """ { "http01": { "ingress": { "ingressClassName": "traefik" } } } """; JsonElement solverElement = JsonDocument.Parse(json).RootElement; // Act Http01Details details = LetsEncryptCheck.ParseHttp01Details(solverElement); // Assert details.Mode.Should().Be("ingress"); details.IngressClass.Should().Be("traefik"); details.GatewayName.Should().BeNull(); details.GatewayNamespace.Should().BeNull(); } [Fact] public void ParseHttp01Details_WithIngressClassField_ExtractsClass() { // Arrange — Some older issuers use "class" instead of "ingressClassName". string json = """ { "http01": { "ingress": { "class": "nginx" } } } """; JsonElement solverElement = JsonDocument.Parse(json).RootElement; // Act Http01Details details = LetsEncryptCheck.ParseHttp01Details(solverElement); // Assert details.Mode.Should().Be("ingress"); details.IngressClass.Should().Be("nginx"); } [Fact] public void ParseHttp01Details_WithNoHttp01_ReturnsNull() { // Arrange — A solver that only has dns01. string json = """ { "dns01": { "cloudflare": {} } } """; JsonElement solverElement = JsonDocument.Parse(json).RootElement; // Act Http01Details? details = LetsEncryptCheck.ParseHttp01Details(solverElement); // Assert details.Should().BeNull(); } [Fact] public void ParseHttp01Details_WithGatewayHTTPRouteNoNamespace_OmitsNamespace() { // Arrange — A gatewayHTTPRoute solver where the parentRef doesn't // specify a namespace (uses the issuer's namespace by default). string json = """ { "http01": { "gatewayHTTPRoute": { "parentRefs": [ { "name": "default-gateway" } ] } } } """; JsonElement solverElement = JsonDocument.Parse(json).RootElement; // Act Http01Details details = LetsEncryptCheck.ParseHttp01Details(solverElement); // Assert details.Mode.Should().Be("gatewayHTTPRoute"); details.GatewayName.Should().Be("default-gateway"); details.GatewayNamespace.Should().BeNull(); } }