File: ProjectResourceBuilderExtensionTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.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 ASPIREPERSISTENCE001 // Resource lifetime APIs are experimental.
#pragma warning disable ASPIRECERTIFICATES001
#pragma warning disable ASPIREPROJECTS001 // WithProjectDefaults is experimental.
#pragma warning disable ASPIREEXTENSION001 // Debug support APIs are experimental.
 
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Hosting.Tests;
 
[Trait("Partition", "2")]
public class ProjectResourceBuilderExtensionTests
{
    [Fact]
    public void ProjectLaunchDefaultsAnnotationIsTaggedWithExpectedExperimentalDiagnostic()
    {
        var attribute = Assert.Single(typeof(ProjectLaunchDefaultsAnnotation).GetCustomAttributes<ExperimentalAttribute>());
 
        Assert.Equal("ASPIREPROJECTS001", attribute.DiagnosticId);
        Assert.Equal("https://aka.ms/aspire/diagnostics/{0}", attribute.UrlFormat);
    }
 
    [Fact]
    public void WithProjectDefaultsIsTaggedWithExpectedExperimentalDiagnostic()
    {
        var method = typeof(ProjectResourceBuilderExtensions).GetMethod(
            nameof(ProjectResourceBuilderExtensions.WithProjectDefaults),
            BindingFlags.Public | BindingFlags.Static);
 
        Assert.NotNull(method);
        var attribute = Assert.Single(method!.GetCustomAttributes<ExperimentalAttribute>());
 
        Assert.Equal("ASPIREPROJECTS001", attribute.DiagnosticId);
        Assert.Equal("https://aka.ms/aspire/diagnostics/{0}", attribute.UrlFormat);
    }
 
    [Fact]
    public void WithPersistentLifetimeAddsPersistenceAnnotation()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var project = builder.AddProject<TestProject>("project", options => options.ExcludeLaunchProfile = true)
            .WithPersistentLifetime();
 
        var annotation = project.Resource.Annotations.OfType<PersistenceAnnotation>().Single();
        Assert.Equal(PersistenceMode.Persistent, annotation.Mode);
    }
 
    [Fact]
    public void WithProjectDefaultsThrowsClearExceptionWhenResourceHasNoProjectMetadata()
    {
        // WithProjectDefaults is public and only constrained to IResourceWithEnvironment /
        // IResourceWithEndpoints / IResourceWithArgs, so nothing stops a caller from invoking it on a
        // resource that never received an IProjectMetadata annotation. Without this guard, the first
        // internal .Single() over that annotation type throws a generic, unhelpful
        // "Sequence contains no elements" exception instead.
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var executable = builder.AddExecutable("exe", "dotnet", ".", "run");
 
        var exception = Assert.Throws<InvalidOperationException>(
            () => executable.WithProjectDefaults(new ProjectResourceOptions { ExcludeLaunchProfile = true }));
 
        Assert.Contains(nameof(IProjectMetadata), exception.Message);
        Assert.Contains(executable.Resource.Name, exception.Message);
    }
 
    [Theory]
    [InlineData(DistributedApplicationOperation.Run)]
    [InlineData(DistributedApplicationOperation.Publish)]
    public void WithProjectDefaultsThrowsWhenAppliedTwice(DistributedApplicationOperation operation)
    {
        // Now that WithProjectDefaults is public, a caller can reach it again on a resource that
        // AddProject already applied it to. Almost all of the wiring is append-only: in run mode the
        // second pass tries to add a second "{name}-rebuilder" resource, which fails on the duplicate
        // name, and in both modes the debug support and environment callbacks would be duplicated.
        using var builder = TestDistributedApplicationBuilder.Create(operation);
 
        var project = builder.AddProject<TestProject>("project", options => options.ExcludeLaunchProfile = true);
 
        var exception = Assert.Throws<InvalidOperationException>(
            () => project.WithProjectDefaults(new ProjectResourceOptions { LaunchProfileName = "https" }));
 
        Assert.Contains("already been applied", exception.Message);
        Assert.Contains(project.Resource.Name, exception.Message);
    }
 
    [Fact]
    public void WithProjectDefaultsAppliesToAProjectResourceThatWasAddedDirectly()
    {
        // ProjectResource adds ProjectLaunchDefaultsAnnotation in its constructor, so the "already
        // applied" guard must key off the flag on the annotation rather than its presence.
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var project = builder.AddResource(new ProjectResource("project"))
                             .WithAnnotation<IProjectMetadata>(new TestProject())
                             .WithProjectDefaults(new ProjectResourceOptions { ExcludeLaunchProfile = true });
 
        Assert.Single(project.Resource.Annotations.OfType<ProjectLaunchDefaultsAnnotation>());
    }
 
    [Fact]
    public void WithProjectDefaultsThrowsWhenResourceHasMultipleProjectMetadataAnnotations()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var project = builder.AddResource(new ProjectResource("project"))
                             .WithAnnotation<IProjectMetadata>(new TestProject())
                             .WithAnnotation<IProjectMetadata>(new OverrideTestProject());
 
        var exception = Assert.Throws<InvalidOperationException>(
            () => project.WithProjectDefaults(new ProjectResourceOptions { ExcludeLaunchProfile = true }));
 
        Assert.Contains(project.Resource.Name, exception.Message);
        Assert.Contains("more than one", exception.Message);
    }
 
    [Theory]
    [InlineData(ResourceAnnotationMutationBehavior.Append, "more than one")]
    [InlineData(ResourceAnnotationMutationBehavior.Replace, "replaced")]
    public void ProjectMetadataConsumersRejectChangesAfterProjectDefaultsAreApplied(
        ResourceAnnotationMutationBehavior mutationBehavior,
        string expectedMessage)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var project = builder.AddProject<TestProject>("project", options => options.ExcludeLaunchProfile = true);
        project.WithAnnotation<IProjectMetadata>(new OverrideTestProject(), mutationBehavior);
 
        var exception = Assert.Throws<InvalidOperationException>(project.Resource.GetProjectMetadata);
 
        Assert.Contains(project.Resource.Name, exception.Message);
        Assert.Contains(expectedMessage, exception.Message);
    }
 
    [Fact]
    public async Task WithProjectDefaultsAddsHttpsCertificateConfigurationForTlsEndpoints()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        builder.Configuration["Parameters:password"] = "test-password";
        var cert = CreateTestCertificateWithPrivateKey();
        var password = builder.AddParameter("password", secret: true);
 
        var resource = builder.AddProject<TestProject>("test", options => options.ExcludeLaunchProfile = true)
            .WithHttpsEndpoint()
            .WithHttpsCertificate(cert, password)
            .Resource;
 
        await builder.BuildAsync();
 
        var context = new ExecutionConfigurationGathererContext();
        await new HttpsCertificateExecutionConfigurationGatherer(CreateHttpsCertificateConfigurationContextFactory())
            .GatherAsync(context, resource, NullLogger.Instance, builder.ExecutionContext);
 
        var certificatePath = Assert.IsAssignableFrom<IValueProvider>(context.EnvironmentVariables[KnownAspNetCoreConfigNames.KestrelCertificatesDefaultPath]);
        Assert.Equal("/etc/ssl/certs/server.pfx", await certificatePath.GetValueAsync());
        Assert.Same(password.Resource, context.EnvironmentVariables[KnownAspNetCoreConfigNames.KestrelCertificatesDefaultPassword]);
 
        var metadata = context.AdditionalConfigurationData.OfType<HttpsCertificateExecutionConfigurationData>().Single();
        Assert.False(metadata.IsKeyPathReferenced);
        Assert.False(metadata.IsCertificateWithKeyPathReferenced);
        Assert.True(metadata.IsPfxPathReferenced);
    }
 
    [Fact]
    public async Task WithProjectDefaultsRemovesStaleKestrelCertificatePassword()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var cert = CreateTestCertificateWithPrivateKey();
 
        var resource = builder.AddProject<TestProject>("test", options => options.ExcludeLaunchProfile = true)
            .WithHttpsEndpoint()
            .WithEnvironment(KnownAspNetCoreConfigNames.KestrelCertificatesDefaultPassword, "stale-password")
            .WithHttpsCertificate(cert)
            .Resource;
 
        await builder.BuildAsync();
 
        var context = new ExecutionConfigurationGathererContext();
        await new EnvironmentVariablesExecutionConfigurationGatherer()
            .GatherAsync(context, resource, NullLogger.Instance, builder.ExecutionContext);
        await new HttpsCertificateExecutionConfigurationGatherer(CreateHttpsCertificateConfigurationContextFactory())
            .GatherAsync(context, resource, NullLogger.Instance, builder.ExecutionContext);
 
        var certificatePath = Assert.IsAssignableFrom<IValueProvider>(context.EnvironmentVariables[KnownAspNetCoreConfigNames.KestrelCertificatesDefaultPath]);
        Assert.Equal("/etc/ssl/certs/server.pfx", await certificatePath.GetValueAsync());
        Assert.DoesNotContain(KnownAspNetCoreConfigNames.KestrelCertificatesDefaultPassword, context.EnvironmentVariables.Keys);
    }
 
    [Fact]
    public async Task WithProjectDefaultsDoesNotAddHttpsCertificateConfigurationWithoutTlsEndpoints()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var cert = CreateTestCertificateWithPrivateKey();
 
        var resource = builder.AddProject<TestProject>("test", options => options.ExcludeLaunchProfile = true)
            .WithHttpsCertificate(cert)
            .Resource;
 
        await builder.BuildAsync();
 
        var context = new ExecutionConfigurationGathererContext();
        await new HttpsCertificateExecutionConfigurationGatherer(CreateHttpsCertificateConfigurationContextFactory())
            .GatherAsync(context, resource, NullLogger.Instance, builder.ExecutionContext);
 
        Assert.Empty(context.EnvironmentVariables);
 
        var metadata = context.AdditionalConfigurationData.OfType<HttpsCertificateExecutionConfigurationData>().Single();
        Assert.False(metadata.IsPfxPathReferenced);
    }
 
    private static X509Certificate2 CreateTestCertificateWithPrivateKey()
    {
        using var rsa = RSA.Create(2048);
        var request = new CertificateRequest(
            new X500DistinguishedName("CN=test"),
            rsa,
            HashAlgorithmName.SHA256,
            RSASignaturePadding.Pkcs1);
 
        return request.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(1));
    }
 
    private static Func<X509Certificate2, HttpsCertificateExecutionConfigurationContext> CreateHttpsCertificateConfigurationContextFactory()
    {
        return cert => new HttpsCertificateExecutionConfigurationContext
        {
            CertificatePath = ReferenceExpression.Create($"/etc/ssl/certs/server.crt"),
            KeyPath = ReferenceExpression.Create($"/etc/ssl/private/server.key"),
            CertificateWithKeyPath = ReferenceExpression.Create($"/etc/ssl/certs/server.pem"),
            PfxPath = ReferenceExpression.Create($"/etc/ssl/certs/server.pfx")
        };
    }
 
    private sealed class TestProject : IProjectMetadata
    {
        public string ProjectPath => "test.csproj";
    }
 
    private sealed class OverrideTestProject : IProjectMetadata
    {
        public string ProjectPath => "override.csproj";
    }
}