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();
}
}