File: CertManagerTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Kubernetes.Tests\Aspire.Hosting.Kubernetes.Tests.csproj (Aspire.Hosting.Kubernetes.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Text;
using System.Text.Json;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Testing;
 
namespace Aspire.Hosting.Kubernetes.Tests;
 
public class CertManagerTests
{
    private const string TestHmacKey = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh_-";
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task WithAcmeServerForPolyglot_ResolvesStringsAndParameters(bool parameterized)
    {
        var builder = TestDistributedApplicationBuilder.Create();
        builder.Configuration["Parameters:acmeurl"] = "https://acme.example.com/directory";
        builder.Configuration["Parameters:acmeemail"] = "ops@contoso.com";
        var url = builder.AddParameter("acmeurl");
        var email = builder.AddParameter("acmeemail");
        var issuer = builder.AddKubernetesEnvironment("env").AddCertManager("cert-manager").AddIssuer("acme");
 
        var result = issuer.WithAcmeServerForPolyglot(
            parameterized ? url : "https://acme.example.com/directory",
            parameterized ? email : "ops@contoso.com");
 
        Assert.Same(issuer, result);
        var spec = Assert.IsType<CertManagerAcmeIssuerSpec>(issuer.Resource.Spec);
        Assert.Equal("https://acme.example.com/directory", await spec.ServerUrl.GetValueAsync(TestContext.Current.CancellationToken));
        Assert.Equal("ops@contoso.com", await spec.Email.GetValueAsync(TestContext.Current.CancellationToken));
    }
 
    [Fact]
    public async Task WithExternalAccountBinding_ResolvesParameters()
    {
        var builder = TestDistributedApplicationBuilder.Create();
        builder.Configuration["Parameters:keyid"] = "account-123";
        var keyId = builder.AddParameter("keyid");
        var hmacKey = builder.AddParameter("hmac", secret: true);
        var issuer = builder.AddKubernetesEnvironment("env").AddCertManager("cert-manager").AddIssuer("acme");
 
        var result = issuer.WithExternalAccountBinding(keyId, hmacKey);
 
        Assert.Same(issuer, result);
        var binding = Assert.IsType<CertManagerExternalAccountBinding>(issuer.Resource.ExternalAccountBinding);
        Assert.Same(hmacKey.Resource, binding.HmacKey);
        Assert.Equal("account-123", await binding.KeyId.GetValueAsync(TestContext.Current.CancellationToken));
    }
 
    [Fact]
    public void PolyglotAcmeConfiguration_RejectsUnsupportedUnionValues()
    {
        var builder = TestDistributedApplicationBuilder.Create();
        var parameter = builder.AddParameter("parameter");
        var issuer = builder.AddKubernetesEnvironment("env").AddCertManager("cert-manager").AddIssuer("acme");
 
        Assert.Throws<ArgumentException>(() => issuer.WithAcmeServerForPolyglot(42, "ops@contoso.com"));
        Assert.Throws<ArgumentException>(() => issuer.WithAcmeServerForPolyglot("https://acme.example.com/directory", 42));
        Assert.Throws<ArgumentException>(() => issuer.WithAcmeServerForPolyglot(null!, "ops@contoso.com"));
        Assert.Throws<ArgumentException>(() => issuer.WithAcmeServerForPolyglot(parameter, "ops@contoso.com"));
        Assert.Throws<ArgumentException>(() => issuer.WithAcmeServerForPolyglot("https://acme.example.com/directory", parameter));
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task ExternalAccountBindingCleanup_InvalidNamespaceWarnsAndContinues(bool ambiguousOverride)
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var certManager = builder.AddKubernetesEnvironment("env").AddCertManager("cert-manager");
        var issuer = certManager.AddIssuer("acme")
            .WithExternalAccountBinding(builder.AddParameter("keyid"), builder.AddParameter("hmac", secret: true));
        if (ambiguousOverride)
        {
            certManager.Resource.HelmChart.Values["extraArgs[0]"] = "--cluster-resource-namespace=accounts";
        }
        else
        {
            certManager.Resource.HelmChart.Values["clusterResourceNamespace"] = "INVALID_NAMESPACE";
        }
        using var app = builder.Build();
        var logger = new FakeLogger();
        await using var reportingStep = await new NullPublishingActivityReporter().CreateStepAsync("cleanup");
        var context = new PipelineStepContext
        {
            PipelineContext = new PipelineContext(
                app.Services.GetRequiredService<DistributedApplicationModel>(),
                new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish),
                app.Services, logger, TestContext.Current.CancellationToken),
            ReportingStep = reportingStep
        };
 
        await CertManagerExtensions.DeleteExternalAccountBindingSecretAsync(context, issuer.Resource);
 
        var warning = Assert.Single(logger.Collector.GetSnapshot());
        Assert.Equal(LogLevel.Warning, warning.Level);
        Assert.Contains("acme-eab", warning.Message);
        Assert.Contains("Remove the Secret manually", warning.Message);
        Assert.Contains("Continuing destroy", warning.Message);
    }
 
    [Fact]
    public async Task WithAcmeServer_ParameterizedUrlResolvesAtDeployTime()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var serverUrl = builder.AddParameter("acmeurl");
        var email = builder.AddParameter("acmeemail");
        var certManager = builder.AddKubernetesEnvironment("env").AddCertManager("cert-manager");
        var issuer = certManager.AddIssuer("parameterized-acme").WithHttp01Solver();
 
        issuer.WithAcmeServer(serverUrl, email);
        var spec = Assert.IsType<CertManagerAcmeIssuerSpec>(issuer.Resource.Spec);
        Assert.Same(serverUrl.Resource, Assert.Single(spec.ServerUrl.ValueProviders));
 
        builder.Configuration["Parameters:acmeurl"] = "https://acme.example.com/directory";
        builder.Configuration["Parameters:acmeemail"] = "ops@contoso.com";
        using var app = builder.Build();
        var yaml = await CertManagerExtensions.BuildClusterIssuerManifestAsync(
            app.Services.GetRequiredService<DistributedApplicationModel>(), certManager.Resource, issuer.Resource,
            NullLogger.Instance, TestContext.Current.CancellationToken);
 
        await Verify(yaml, "yaml");
    }
 
    [Fact]
    public void WithAcmeServer_ParameterizedUrlValidatesArguments()
    {
        var builder = TestDistributedApplicationBuilder.Create();
        var serverUrl = builder.AddParameter("acmeurl");
        var email = builder.AddParameter("acmeemail");
        var issuer = builder.AddKubernetesEnvironment("env").AddCertManager("cert-manager").AddIssuer("acme");
 
        Assert.Throws<ArgumentNullException>(() => CertManagerExtensions.WithAcmeServer(null!, serverUrl, email));
        Assert.Throws<ArgumentNullException>(() => issuer.WithAcmeServer((IResourceBuilder<ParameterResource>)null!, email));
        Assert.Throws<ArgumentNullException>(() => issuer.WithAcmeServer(serverUrl, (IResourceBuilder<ParameterResource>)null!));
    }
 
    [Fact]
    public void WithExternalAccountBinding_StoresCredentialsWithoutResolvingParameters()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var issuer = builder.AddKubernetesEnvironment("env").AddCertManager("cert-manager").AddIssuer("eab");
        var hmacKey = builder.AddParameter("eab-hmac", secret: true);
        var keyId = builder.AddParameter("eab-key-id");
 
        var result = issuer.WithExternalAccountBinding(keyId, hmacKey);
        issuer.WithAcmeServer("https://acme.example.com/directory", "ops@contoso.com");
 
        Assert.Same(issuer, result);
        var binding = Assert.IsType<CertManagerExternalAccountBinding>(issuer.Resource.ExternalAccountBinding);
        Assert.Same(hmacKey.Resource, binding.HmacKey);
        Assert.Equal("{0}", binding.KeyId.Format);
        Assert.Same(keyId.Resource, Assert.Single(binding.KeyId.ValueProviders));
    }
 
    [Fact]
    public void WithExternalAccountBinding_RejectsNonSecretHmacKey()
    {
        var builder = TestDistributedApplicationBuilder.Create();
        var issuer = builder.AddKubernetesEnvironment("env").AddCertManager("cert-manager").AddIssuer("eab");
        var hmacKey = builder.AddParameter("eab-hmac");
        var keyId = builder.AddParameter("eab-key-id");
 
        var exception = Assert.Throws<ArgumentException>(() => issuer.WithExternalAccountBinding(keyId, hmacKey));
        Assert.Equal("hmacKey", exception.ParamName);
        Assert.Null(issuer.Resource.ExternalAccountBinding);
    }
 
    [Fact]
    public void WithExternalAccountBinding_ValidatesArguments()
    {
        var builder = TestDistributedApplicationBuilder.Create();
        var issuer = builder.AddKubernetesEnvironment("env").AddCertManager("cert-manager").AddIssuer("eab");
        var hmacKey = builder.AddParameter("eab-hmac", secret: true);
        var keyId = builder.AddParameter("eab-key-id");
 
        Assert.Throws<ArgumentNullException>(() => CertManagerExtensions.WithExternalAccountBinding(null!, keyId, hmacKey));
        Assert.Throws<ArgumentNullException>(() => issuer.WithExternalAccountBinding((IResourceBuilder<ParameterResource>)null!, hmacKey));
        Assert.Throws<ArgumentNullException>(() => issuer.WithExternalAccountBinding(keyId, null!));
    }
 
    [Theory]
    [InlineData(null, null, null, "cert-manager-chart")]
    [InlineData("controllers", null, null, "controllers")]
    [InlineData("controllers", "accounts", null, "accounts")]
    [InlineData("controllers", "", null, "controllers")]
    [InlineData("controllers", null, "chart-override", "chart-override")]
    [InlineData("controllers", null, "", "controllers")]
    public void ExternalAccountBinding_UsesClusterResourceNamespace(
        string? installNamespace, string? clusterNamespace, string? chartNamespace, string expected)
    {
        var builder = TestDistributedApplicationBuilder.Create();
        var certManager = builder.AddKubernetesEnvironment("env").AddCertManager("cert-manager");
        certManager.Resource.HelmChart.Namespace = installNamespace;
        if (clusterNamespace is not null)
        {
            certManager.Resource.HelmChart.Values["clusterResourceNamespace"] = clusterNamespace;
        }
        if (chartNamespace is not null)
        {
            certManager.Resource.HelmChart.Values["namespace"] = chartNamespace;
        }
 
        Assert.Equal(expected, CertManagerExtensions.GetClusterResourceNamespace(certManager.Resource, validateEabConfiguration: true));
    }
 
    [Fact]
    public void ExternalAccountBinding_RejectsAmbiguousNamespaceOverride()
    {
        var builder = TestDistributedApplicationBuilder.Create();
        var certManager = builder.AddKubernetesEnvironment("env").AddCertManager("cert-manager");
        certManager.Resource.HelmChart.Values["extraArgs[0]"] = "--cluster-resource-namespace=accounts";
 
        var exception = Assert.Throws<InvalidOperationException>(() =>
            CertManagerExtensions.GetClusterResourceNamespace(certManager.Resource, validateEabConfiguration: true));
        Assert.Contains("'clusterResourceNamespace' Helm value", exception.Message);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task IssuerPipeline_ValidatesSpecAndSolverBeforeInvokingKubectl(bool withSpec)
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var certManager = builder.AddKubernetesEnvironment("env").AddCertManager("cert-manager");
        var issuer = certManager.AddIssuer("eab");
        if (withSpec)
        {
            issuer.WithAcmeServer("https://acme.example.com/directory", "ops@contoso.com");
        }
        using var app = builder.Build();
        var steps = await PipelineStepTestHelpers.CreateStepsAsync(app.Services, certManager.Resource);
        var apply = Assert.Single(steps, step => step.Name == "cm-issuer-apply-eab");
        Assert.Contains("helm-install-cert-manager-chart", apply.DependsOnSteps);
        var delete = Assert.Single(steps, step => step.Name == "cm-issuer-delete-eab");
        Assert.Contains("helm-uninstall-cert-manager-chart", delete.RequiredBySteps);
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
            PipelineStepTestHelpers.RunStepAsync(app.Services, apply));
        Assert.Equal(withSpec
            ? "ClusterIssuer 'eab' has no solvers configured. Add at least one solver via WithHttp01Solver()."
            : "ClusterIssuer 'eab' has no spec. Configure it with WithAcmeServer().", exception.Message);
    }
 
    [Theory]
    [InlineData("")]
    [InlineData("issuer-eab:aspire-cert-manager")]
    public void ExternalAccountBinding_AcceptsMissingOrOwnedSecret(string ownership)
    {
        CertManagerExtensions.ValidateExternalAccountBindingSecretOwnership("issuer-eab", ownership);
    }
 
    [Theory]
    [InlineData("issuer-eab:")]
    [InlineData("issuer-eab:another-manager")]
    public void ExternalAccountBinding_RejectsUnmanagedSecret(string ownership)
    {
        Assert.Throws<InvalidOperationException>(() =>
            CertManagerExtensions.ValidateExternalAccountBindingSecretOwnership("issuer-eab", ownership));
    }
 
    [Fact]
    public async Task BuildExternalAccountBindingSecretApplySpec_UsesStdinAndPreservesBase64UrlText()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        builder.Configuration["Parameters:eab-hmac"] = TestHmacKey;
        var hmacKey = builder.AddParameter("eab-hmac", secret: true);
        var issuer = builder.AddKubernetesEnvironment("env").AddCertManager("cert-manager").AddIssuer("Eab-Issuer")
            .WithExternalAccountBinding(builder.AddParameter("eab-key-id"), hmacKey);
 
        var spec = await CertManagerExtensions.BuildExternalAccountBindingSecretApplySpecAsync(
            issuer.Resource, resourceVersion: null, TestContext.Current.CancellationToken);
 
        Assert.Equal(["create", "-f", "-"], spec.ArgumentList);
        Assert.Null(spec.Arguments);
        Assert.Null(spec.OnOutputData);
        Assert.Null(spec.OnErrorData);
        Assert.False(spec.ThrowOnNonZeroReturnCode);
        Assert.Equal(0, spec.RetainedOutputLineCount);
        using var document = JsonDocument.Parse(spec.StandardInputContent!);
        var encodedKey = document.RootElement.GetProperty("data").GetProperty("hmac-key").GetString()!;
        Assert.Equal(TestHmacKey, Encoding.UTF8.GetString(Convert.FromBase64String(encodedKey)));
        await Verify(spec.StandardInputContent!, "json");
    }
 
    [Fact]
    public async Task ExternalAccountBinding_UsesConditionalReplacementForOwnedSecret()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        builder.Configuration["Parameters:eab-hmac"] = TestHmacKey;
        var hmacKey = builder.AddParameter("eab-hmac", secret: true);
        var issuer = builder.AddKubernetesEnvironment("env").AddCertManager("cert-manager").AddIssuer("eab")
            .WithExternalAccountBinding(builder.AddParameter("eab-key-id"), hmacKey);
        var resourceVersion = CertManagerExtensions.ParseExternalAccountBindingSecretResourceVersion(
            "eab-eab", "eab-eab:aspire-cert-manager:12345");
 
        var spec = await CertManagerExtensions.BuildExternalAccountBindingSecretApplySpecAsync(
            issuer.Resource, resourceVersion, TestContext.Current.CancellationToken);
 
        Assert.Equal(["replace", "-f", "-"], spec.ArgumentList);
        Assert.Null(spec.OnOutputData);
        Assert.Null(spec.OnErrorData);
        Assert.Equal(0, spec.RetainedOutputLineCount);
        await Verify(spec.StandardInputContent!, "json");
    }
 
    [Theory]
    [InlineData("eab-eab::12345")]
    [InlineData("eab-eab:another-manager:12345")]
    [InlineData("eab-eab:aspire-cert-manager:")]
    [InlineData("malformed")]
    public void ExternalAccountBinding_RejectsUnownedOrUnversionedSecret(string output)
    {
        Assert.Throws<InvalidOperationException>(() =>
            CertManagerExtensions.ParseExternalAccountBindingSecretResourceVersion("eab-eab", output));
    }
 
    [Fact]
    public void ExternalAccountBinding_NonEabCleanupDoesNotValidateNamespaceOverrides()
    {
        var builder = TestDistributedApplicationBuilder.Create();
        var certManager = builder.AddKubernetesEnvironment("env").AddCertManager("cert-manager");
        certManager.Resource.HelmChart.Values["extraArgs[0]"] = "--cluster-resource-namespace=accounts";
        Assert.Equal("cert-manager-chart",
            CertManagerExtensions.GetClusterResourceNamespace(certManager.Resource, validateEabConfiguration: false));
    }
 
    [Fact]
    public async Task BuildClusterIssuerManifest_ExternalAccountBindingEscapesKeyId()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        const string keyIdValue = "fixture: \"account\"\n#key";
        builder.Configuration["Parameters:eab-key-id"] = keyIdValue;
        var keyId = builder.AddParameter("eab-key-id");
        var hmacKey = builder.AddParameter("eab-hmac", secret: true);
        var certManager = builder.AddKubernetesEnvironment("env").AddCertManager("cert-manager");
        var issuer = certManager.AddIssuer("eab")
            .WithAcmeServer("https://acme.example.com/directory", "ops@contoso.com")
            .WithHttp01Solver();
        issuer.WithExternalAccountBinding(keyId, hmacKey);
 
        using var app = builder.Build();
        var yaml = await CertManagerExtensions.BuildClusterIssuerManifestAsync(
            app.Services.GetRequiredService<DistributedApplicationModel>(), certManager.Resource, issuer.Resource,
            NullLogger.Instance, TestContext.Current.CancellationToken);
 
        await Verify(yaml, "yaml");
    }
 
    [Fact]
    public void AddCertManager_RegistersWrapperAndHelmChartUnderSeparateNames()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
 
        var certManager = k8s.AddCertManager("cert-manager");
 
        Assert.Equal("cert-manager", certManager.Resource.Name);
        Assert.Same(k8s.Resource, certManager.Resource.Parent);
 
        // The wrapper keeps the natural "{name}" identifier; the helm chart is registered
        // under "{name}-chart" so both can coexist in the model without colliding.
        Assert.Equal("cert-manager-chart", certManager.Resource.HelmChart.Name);
        Assert.Equal("oci://quay.io/jetstack/charts/cert-manager", certManager.Resource.HelmChart.ChartReference);
        Assert.StartsWith("v", certManager.Resource.HelmChart.ChartVersion);
 
        // CRDs and Gateway API support are required for cert-manager to issue certificates
        // for Aspire-modeled Gateway resources, so they must be on by default.
        Assert.Equal("true", certManager.Resource.HelmChart.Values["crds.enabled"]);
        Assert.Equal("true", certManager.Resource.HelmChart.Values["config.enableGatewayAPI"]);
 
        var app = builder.Build();
        var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
        Assert.Contains(appModel.Resources, r => r is CertManagerResource c && c.Name == "cert-manager");
        Assert.Contains(appModel.Resources, r => r is KubernetesHelmChartResource c && c.Name == "cert-manager-chart");
    }
 
    [Fact]
    public void AddIssuer_WithAcmeServerAndHttp01_PopulatesSpecAndSolver()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
        var certManager = k8s.AddCertManager("cert-manager");
 
        var issuer = certManager.AddIssuer("letsencrypt-prod")
            .WithAcmeServer("https://acme-v02.api.letsencrypt.org/directory", "ops@contoso.com")
            .WithHttp01Solver();
 
        Assert.Equal("letsencrypt-prod", issuer.Resource.Name);
        Assert.Same(certManager.Resource, issuer.Resource.Parent);
        Assert.Contains(issuer.Resource, certManager.Resource.Issuers);
 
        var spec = Assert.IsType<CertManagerAcmeIssuerSpec>(issuer.Resource.Spec);
        Assert.Equal("https://acme-v02.api.letsencrypt.org/directory", spec.ServerUrl.Format);
        Assert.Equal("ops@contoso.com", spec.Email.Format);
 
        var solver = Assert.Single(issuer.Resource.Solvers);
        Assert.IsType<CertManagerHttp01SolverConfig>(solver);
    }
 
    [Fact]
    public void WithAcmeServer_UsesStagingDirectoryUrl()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var certManager = builder.AddKubernetesEnvironment("env").AddCertManager("cert-manager");
 
        var issuer = certManager.AddIssuer("le-staging")
            .WithAcmeServer("https://acme-staging-v02.api.letsencrypt.org/directory", "ops@contoso.com");
 
        var spec = Assert.IsType<CertManagerAcmeIssuerSpec>(issuer.Resource.Spec);
        Assert.Equal("https://acme-staging-v02.api.letsencrypt.org/directory", spec.ServerUrl.Format);
    }
 
    [Fact]
    public void WithAcmeServer_AllowsCustomDirectoryUrl()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var certManager = builder.AddKubernetesEnvironment("env").AddCertManager("cert-manager");
 
        var issuer = certManager.AddIssuer("custom-acme")
            .WithAcmeServer("https://acme.example.com/directory", "ops@contoso.com");
 
        var spec = Assert.IsType<CertManagerAcmeIssuerSpec>(issuer.Resource.Spec);
        Assert.Equal("https://acme.example.com/directory", spec.ServerUrl.Format);
    }
 
    [Fact]
    public void Gateway_WithTls_Issuer_AddsClusterIssuerAnnotation()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
        var issuer = k8s.AddCertManager("cert-manager")
            .AddIssuer("letsencrypt-prod")
            .WithAcmeServer("https://acme-v02.api.letsencrypt.org/directory", "ops@contoso.com")
            .WithHttp01Solver();
 
        var gateway = k8s.AddGateway("public")
            .WithGatewayClass("nginx")
            .WithTls(issuer);
 
        // The typed WithTls(issuer) overload should be equivalent to WithTls() + setting
        // the cert-manager.io/cluster-issuer annotation to the issuer's name.
        Assert.Single(gateway.Resource.TlsConfigs);
        Assert.True(gateway.Resource.GatewayAnnotations.TryGetValue(
            CertManagerExtensions.ClusterIssuerAnnotationKey, out var value));
        Assert.Equal("letsencrypt-prod", value!.Format);
    }
 
    [Fact]
    public async Task BuildClusterIssuerManifest_EmitsExpectedYamlForLetsEncryptHttp01()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
        var issuer = k8s.AddCertManager("cert-manager")
            .AddIssuer("LetsEncrypt-Prod") // intentionally mixed-case to verify DNS-1123 normalization
            .WithAcmeServer("https://acme-v02.api.letsencrypt.org/directory", "ops@contoso.com")
            .WithHttp01Solver();
 
        // A gateway that adopts this issuer must end up referenced as a parentRef on the
        // generated solver, so cert-manager's HTTP-01 HTTPRoute can attach to it. The gateway
        // needs a route, otherwise it is skipped during materialization and referencing it
        // would produce a parentRef to a Gateway that never exists in the cluster.
        var api = builder.AddContainer("myapi", "nginx")
            .WithHttpEndpoint(targetPort: 8080)
            .WithExternalHttpEndpoints();
 
        k8s.AddGateway("PUBLIC-GW")
            .WithGatewayClass("nginx")
            .WithRoute("/", api.GetEndpoint("http"))
            .WithTls(issuer);
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var certManagerResource = model.Resources.OfType<CertManagerResource>().Single();
        var issuerResource = certManagerResource.Issuers.Single();
 
        var yaml = await CertManagerExtensions.BuildClusterIssuerManifestAsync(
            model,
            certManagerResource,
            issuerResource,
            NullLogger.Instance,
            TestContext.Current.CancellationToken);
 
        // Object names go through ToKubernetesResourceName() (lowercasing) so they're valid
        // DNS-1123 subdomains regardless of the casing the user picked for the Aspire name.
        Assert.Contains("apiVersion: cert-manager.io/v1", yaml);
        Assert.Contains("kind: ClusterIssuer", yaml);
        Assert.Contains("name: letsencrypt-prod", yaml);
        Assert.Contains("server: \"https://acme-v02.api.letsencrypt.org/directory\"", yaml);
        Assert.Contains("email: \"ops@contoso.com\"", yaml);
        Assert.Contains("name: letsencrypt-prod-account-key", yaml);
        Assert.Contains("- http01:", yaml);
        Assert.Contains("gatewayHTTPRoute:", yaml);
        // Gateway parentRef must also be lowercase to match the actual emitted Gateway name.
        Assert.Contains("name: public-gw", yaml);
    }
 
    [Fact]
    public async Task BuildClusterIssuerManifest_RouteLessGateway_IsNotEmittedAsParentRef()
    {
        // A gateway with no routes is skipped during materialization, so emitting it as a
        // solver parentRef would point cert-manager's HTTP-01 HTTPRoute at a Gateway that
        // never exists in the cluster, leaving Certificates stuck in 'Pending' forever.
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
        var issuer = k8s.AddCertManager("cert-manager")
            .AddIssuer("le-prod")
            .WithAcmeServer("https://acme-v02.api.letsencrypt.org/directory", "ops@contoso.com")
            .WithHttp01Solver();
 
        k8s.AddGateway("public")
            .WithGatewayClass("nginx")
            .WithTls(issuer);
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var certManagerResource = model.Resources.OfType<CertManagerResource>().Single();
        var issuerResource = certManagerResource.Issuers.Single();
 
        var yaml = await CertManagerExtensions.BuildClusterIssuerManifestAsync(
            model,
            certManagerResource,
            issuerResource,
            NullLogger.Instance,
            TestContext.Current.CancellationToken);
 
        // The solver is still emitted, but with no parentRefs block at all, which is also what
        // makes the "no routed Gateway adopts this issuer" warning fire.
        Assert.Contains("- http01:", yaml);
        Assert.Contains("gatewayHTTPRoute:", yaml);
        Assert.DoesNotContain("parentRefs:", yaml);
        Assert.DoesNotContain("name: public", yaml);
    }
 
    [Fact]
    public async Task BuildClusterIssuerManifest_RouteLessGateway_WarnsThatChallengesCannotBeSatisfied()
    {
        // Without a parentRef the manifest is still valid YAML and cert-manager accepts it, so the
        // warning is the only thing that tells the user why their Certificates never leave
        // 'Pending'. Assert it explicitly so it cannot be dropped without a test failing.
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
        var issuer = k8s.AddCertManager("cert-manager")
            .AddIssuer("le-prod")
            .WithAcmeServer("https://acme-v02.api.letsencrypt.org/directory", "ops@contoso.com")
            .WithHttp01Solver();
 
        k8s.AddGateway("public")
            .WithGatewayClass("nginx")
            .WithTls(issuer);
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var certManagerResource = model.Resources.OfType<CertManagerResource>().Single();
        var issuerResource = certManagerResource.Issuers.Single();
 
        var testSink = new TestSink();
        using var loggerProvider = new TestLoggerProvider(testSink);
 
        await CertManagerExtensions.BuildClusterIssuerManifestAsync(
            model,
            certManagerResource,
            issuerResource,
            loggerProvider.CreateLogger("test"),
            TestContext.Current.CancellationToken);
 
        var warning = Assert.Single(testSink.Writes, w => w.LogLevel == LogLevel.Warning);
 
        Assert.Equal(
            "ClusterIssuer 'le-prod' has an HTTP-01 solver but no Gateway in environment 'env' is both annotated with " +
            "cert-manager.io/cluster-issuer=le-prod and configured with at least one route. cert-manager will not be able to " +
            "satisfy ACME challenges until at least one routed Gateway adopts this issuer (e.g. via WithRoute(...) and WithTls(issuer)).",
            warning.Message);
    }
 
    [Fact]
    public void Gateway_WithTls_Issuer_FromDifferentEnvironment_Throws()
    {
        // cert-manager is per-cluster, so an issuer can only TLS-protect gateways inside the
        // same Kubernetes environment. Cross-env wiring is a configuration bug we want to
        // catch at app-host build time, not at deploy time when nothing comes up.
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var envA = builder.AddKubernetesEnvironment("env-a");
        var envB = builder.AddKubernetesEnvironment("env-b");
 
        var issuerInA = envA.AddCertManager("cert-manager")
            .AddIssuer("le-prod")
            .WithAcmeServer("https://acme-v02.api.letsencrypt.org/directory", "ops@contoso.com")
            .WithHttp01Solver();
 
        var gatewayInB = envB.AddGateway("public").WithGatewayClass("nginx");
 
        Assert.Throws<InvalidOperationException>(() => gatewayInB.WithTls(issuerInA));
    }
 
    [Fact]
    public void AddCertManager_RunMode_DoesNotRegisterResources()
    {
        // In run mode neither the wrapper nor its helm chart get added to the model
        // (helm install only runs at deploy time, and AddHelmChart already suppresses
        // its resource in run mode).
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var k8s = builder.AddKubernetesEnvironment("env");
 
        var certManager = k8s.AddCertManager("cert-manager");
        Assert.NotNull(certManager.Resource);
 
        var app = builder.Build();
        var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
        Assert.DoesNotContain(appModel.Resources, r => r is CertManagerResource);
    }
}