File: AzureContainerAppExpressTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Azure.Tests\Aspire.Hosting.Azure.Tests.csproj (Aspire.Hosting.Azure.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIREACAEXPRESS001
#pragma warning disable ASPIREPIPELINES001
 
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Azure.AppContainers;
using Aspire.Hosting.Utils;
using Azure.Provisioning;
using Azure.Provisioning.AppContainers;
using static Aspire.Hosting.Utils.AzureManifestUtils;
 
namespace Aspire.Hosting.Azure.Tests;
 
public class AzureContainerAppExpressTests(ITestOutputHelper testOutputHelper)
{
    [Fact]
    public void AsExpressValidatesBuilder()
    {
        IResourceBuilder<AzureContainerAppEnvironmentResource> builder = null!;
 
        var exception = Assert.Throws<ArgumentNullException>(builder.AsExpress);
 
        Assert.Equal("builder", exception.ParamName);
    }
 
    [Fact]
    public async Task AsExpressIsIdempotentAndDoesNotChangeRunResources()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        var environment = builder.AddAzureContainerAppEnvironment("env").WithHttpsUpgrade(false);
        var resources = builder.Resources.ToArray();
 
        Assert.Same(environment, environment.AsExpress());
        Assert.Same(environment, environment.AsExpress());
 
        using var application = builder.Build();
        await ExecuteBeforeStartHooksAsync(application, default);
 
        Assert.Equal(resources, builder.Resources);
    }
 
    [Theory]
    [InlineData(false, false)]
    [InlineData(false, true)]
    [InlineData(true, false)]
    [InlineData(true, true)]
    public async Task AsExpressRejectsDisabledHttpsUpgradeWhenGeneratingBicep(bool expressFirst, bool existing)
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, testOutputHelper);
        var environment = builder.AddAzureContainerAppEnvironment("env");
        if (expressFirst)
        {
            environment.AsExpress().WithHttpsUpgrade(false);
        }
        else
        {
            environment.WithHttpsUpgrade(false).AsExpress();
        }
        if (existing)
        {
            environment.AsExisting(builder.AddParameter("existing-name"), builder.AddParameter("existing-resource-group"));
        }
 
        var api = builder.AddContainer("api", "myimage")
            .WithHttpEndpoint(targetPort: 8080)
            .WithExternalHttpEndpoints();
        builder.AddContainer("web", "myimage")
            .WithHttpEndpoint(targetPort: 8080)
            .WithReference(api.GetEndpoint("http"));
 
        var exception = Assert.Throws<InvalidOperationException>(environment.Resource.GetBicepTemplateString);
 
        Assert.Equal(
            "Azure Container Apps Express environment 'env' cannot use WithHttpsUpgrade(false) because Express requires HTTPS ingress. " +
            "Remove WithHttpsUpgrade(false) or use WithHttpsUpgrade(true).",
            exception.Message);
 
        using var application = builder.Build();
        var pipelineException = await Assert.ThrowsAsync<InvalidOperationException>(
            () => ExecuteBeforeStartHooksAsync(application, default));
 
        Assert.Equal(exception.Message, pipelineException.Message);
    }
 
    [Fact]
    public async Task AsExpressDefaultsAreEnvironmentLocal()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, testOutputHelper);
        var express = builder.AddAzureContainerAppEnvironment("express").AsExpress();
        var standard = builder.AddAzureContainerAppEnvironment("standard");
 
        var (_, expressBicep) = await GetManifestWithBicep(express.Resource);
        var (_, standardBicep) = await GetManifestWithBicep(standard.Resource);
 
        await Verify(expressBicep, "bicep").AppendContentAsFile(standardBicep, "bicep", "standard");
    }
 
    [Fact]
    public async Task AsExpressPublishesExistingEnvironment()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, testOutputHelper);
        // The new-environment module is covered by AsExpressDefaultsAreEnvironmentLocal; this
        // covers only the thin existing-environment module, which has a different shape.
        var environment = builder.AddAzureContainerAppEnvironment("env").AsExpress()
            .WithHttpsUpgrade(false)
            .WithHttpsUpgrade(true)
            .AsExisting(builder.AddParameter("existing-name"), builder.AddParameter("existing-resource-group"));
 
        var (manifest, bicep) = await GetManifestWithBicep(environment.Resource);
 
        await Verify(manifest.ToString(), "json").AppendContentAsFile(bicep, "bicep");
    }
 
    [Fact]
    public async Task AsExpressPreservesExplicitDashboardRegardlessOfCallOrder()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, testOutputHelper);
        var expressFirst = builder.AddAzureContainerAppEnvironment("env");
        expressFirst.AsExpress().WithDashboard();
 
        using var reversedBuilder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, testOutputHelper);
        var dashboardFirst = reversedBuilder.AddAzureContainerAppEnvironment("env");
        dashboardFirst.WithDashboard().AsExpress();
 
        var expressFirstBicep = expressFirst.Resource.GetBicepTemplateString();
 
        // Call order is the whole point of this test, so assert equivalence directly rather than
        // maintaining two snapshots that must stay byte-identical to prove it.
        Assert.Equal(expressFirstBicep, dashboardFirst.Resource.GetBicepTemplateString());
 
        await Verify(expressFirstBicep, "bicep");
    }
 
    [Fact]
    public async Task AsExpressSupportsResourcesWithoutEndpoints()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, testOutputHelper);
        builder.AddAzureContainerAppEnvironment("env").AsExpress();
        var worker = builder.AddContainer("worker", "myimage");
 
        using var application = builder.Build();
        await ExecuteBeforeStartHooksAsync(application, default);
        var target = Assert.IsType<AzureContainerAppResource>(worker.Resource.GetDeploymentTargetAnnotation()!.DeploymentTarget);
 
        await Verify(target.GetBicepTemplateString(), "bicep");
    }
 
    [Fact]
    public async Task AsExpressPreservesEnvironmentCustomization()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, testOutputHelper);
        var environment = builder.AddAzureContainerAppEnvironment("env").AsExpress()
            .ConfigureInfrastructure(infrastructure =>
            {
                var managedEnvironment = infrastructure.GetProvisionableResources().OfType<ContainerAppManagedEnvironment>().Single();
                managedEnvironment.WorkloadProfiles.Add(new ContainerAppWorkloadProfile
                {
                    Name = "custom",
                    WorkloadProfileType = "Consumption"
                });
            });
 
        await Verify(environment.Resource.GetBicepTemplateString(), "bicep");
    }
 
    [Theory]
    [InlineData(null, null)]
    [InlineData(3, null)]
    [InlineData(3, 2)]
    public async Task AsExpressProjectPreservesExplicitReplicaSettings(int? replicas, int? customMinimum)
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, testOutputHelper);
        builder.AddAzureContainerAppEnvironment("env").AsExpress();
        var project = builder.AddProject("api", "api.csproj", options => options.ExcludeLaunchProfile = true)
            .WithHttpEndpoint(targetPort: 8080)
            .WithExternalHttpEndpoints();
        if (replicas is { } count)
        {
            project.WithReplicas(count);
        }
        if (customMinimum is { } minimum)
        {
            project.PublishAsAzureContainerApp((_, app) => app.Template.Scale.MinReplicas = minimum);
        }
 
        using var application = builder.Build();
        await ExecuteBeforeStartHooksAsync(application, default);
        var target = Assert.IsType<AzureContainerAppResource>(project.Resource.GetDeploymentTargetAnnotation()!.DeploymentTarget);
 
        // Only the scale block varies across these cases. The manifest is identical for all of
        // them and its shape is already covered by the manual-secret and existing-environment tests.
        await Verify(target.GetBicepTemplateString(), "bicep");
    }
 
    [Fact]
    public async Task AsExpressPreservesManualSecretsIdentityAndSupportedCustomization()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, testOutputHelper);
        builder.AddAzureContainerAppEnvironment("env").AsExpress();
        var secret = builder.AddParameter("api-key", secret: true);
        var storage = builder.AddAzureStorage("storage");
        var project = builder.AddProject("api", "api.csproj", options => options.ExcludeLaunchProfile = true)
            .WithHttpEndpoint(targetPort: 8080)
            .WithExternalHttpEndpoints()
            .WithEnvironment("API_KEY", secret)
            .WithReference(storage.AddBlobs("blobs"))
            .PublishAsAzureContainerApp((_, app) =>
            {
                app.Template.Volumes.Add(new ContainerAppVolume { Name = "scratch", StorageType = ContainerAppStorageType.EmptyDir });
                var container = app.Template.Containers[0].Value!;
                // Avoid the OS temp directory: Verify scrubs it to {TempPath}, which would make
                // this snapshot platform dependent.
                container.VolumeMounts.Add(new ContainerAppVolumeMount { VolumeName = "scratch", MountPath = "/scratch" });
                container.Probes.Add(new ContainerAppProbe
                {
                    ProbeType = ContainerAppProbeType.Liveness,
                    TcpSocket = new ContainerAppTcpSocketRequestInfo { Port = 8080 }
                });
                app.Template.Scale.Rules.Add(new ContainerAppScaleRule
                {
                    Name = "http",
                    Http = new ContainerAppHttpScaleRule { Metadata = { ["concurrentRequests"] = "20" } }
                });
            });
 
        using var application = builder.Build();
        await ExecuteBeforeStartHooksAsync(application, default);
        var target = Assert.IsType<AzureContainerAppResource>(project.Resource.GetDeploymentTargetAnnotation()!.DeploymentTarget);
        var (manifest, bicep) = await GetManifestWithBicep(target, skipPreparer: true);
 
        await Verify(manifest.ToString(), "json").AppendContentAsFile(bicep, "bicep");
    }
 
    [Fact]
    public async Task AsExpressPreservesParameterizedSettingsAndDisabledFeatures()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, testOutputHelper);
        builder.AddAzureContainerAppEnvironment("env").WithDashboard(false).AsExpress();
        var container = builder.AddContainer("api", "myimage")
            .WithHttpEndpoint(targetPort: 8080).WithExternalHttpEndpoints()
            .PublishAsAzureContainerApp((infrastructure, app) =>
            {
                var minimum = new ProvisioningParameter("minimumReplicas", typeof(int));
                var port = new ProvisioningParameter("targetPort", typeof(int));
                infrastructure.Add(minimum);
                infrastructure.Add(port);
                app.Template.Scale.MinReplicas = minimum;
                app.Configuration.Ingress.TargetPort = port;
                app.Configuration.Ingress.ClientCertificateMode = ContainerAppIngressClientCertificateMode.Ignore;
                app.Configuration.Ingress.StickySessionsAffinity = StickySessionAffinity.None;
                app.Configuration.Dapr.IsEnabled = false;
                app.Template.Scale.Rules.Add(new ContainerAppScaleRule
                {
                    Name = "cpu",
                    Custom = new ContainerAppCustomScaleRule { CustomScaleRuleType = "cpu", Metadata = { ["type"] = "Utilization", ["value"] = "80" } }
                });
                app.Template.Scale.Rules.Add(new ContainerAppScaleRule
                {
                    Name = "memory",
                    Custom = new ContainerAppCustomScaleRule { CustomScaleRuleType = "memory", Metadata = { ["type"] = "Utilization", ["value"] = "80" } }
                });
            });
        using var application = builder.Build();
        await ExecuteBeforeStartHooksAsync(application, default);
        var target = Assert.IsType<AzureContainerAppResource>(container.Resource.GetDeploymentTargetAnnotation()!.DeploymentTarget);
 
        await Verify(target.GetBicepTemplateString(), "bicep");
    }
 
    [Fact]
    public async Task AsExpressPreservesKeyVaultSecretReferences()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, testOutputHelper);
        builder.AddAzureContainerAppEnvironment("env").AsExpress();
        var container = builder.AddContainer("api", "myimage")
            .WithHttpEndpoint(targetPort: 8080).WithExternalHttpEndpoints()
            .PublishAsAzureContainerApp((_, app) => app.Configuration.Secrets.Add(new ContainerAppWritableSecret
            {
                Name = "external-secret",
                KeyVaultUri = new Uri("https://example.vault.azure.net/secrets/example"),
                Identity = "/subscriptions/example/resourceGroups/example/providers/Microsoft.ManagedIdentity/userAssignedIdentities/example"
            }));
        using var application = builder.Build();
        await ExecuteBeforeStartHooksAsync(application, default);
        var target = Assert.IsType<AzureContainerAppResource>(container.Resource.GetDeploymentTargetAnnotation()!.DeploymentTarget);
 
        await Verify(target.GetBicepTemplateString(), "bicep");
    }
}