File: EntraIdKeyVaultCertificateTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Azure.EntraId.Tests\Aspire.Hosting.Azure.EntraId.Tests.csproj (Aspire.Hosting.Azure.EntraId.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 ASPIREAZURE003 // Verifies role assignments and resource dependencies.
 
using System.Runtime.CompilerServices;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using Azure.Provisioning.KeyVault;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Identity.Abstractions;
 
namespace Aspire.Hosting.Azure.EntraId.Tests;
 
public class EntraIdKeyVaultCertificateTests
{
    private const string TenantId = "aaaabbbb-0000-cccc-1111-dddd2222eeee";
    private const string ClientId = "00001111-aaaa-2222-bbbb-3333cccc4444";
 
    [Theory]
    [InlineData(DistributedApplicationOperation.Run, false)]
    [InlineData(DistributedApplicationOperation.Run, true)]
    [InlineData(DistributedApplicationOperation.Publish, false)]
    [InlineData(DistributedApplicationOperation.Publish, true)]
    public async Task ResourceCredential_PreservesVaultReference(
        DistributedApplicationOperation operation, bool configureCredentialAfterReference)
    {
        using var builder = TestDistributedApplicationBuilder.Create(operation);
        var vault = builder.AddAzureKeyVault("vault");
        vault.Resource.Outputs["vaultUri"] = "https://contoso.vault.usgovcloudapi.net/";
        var entra = builder.AddExistingEntraApplication("entra", TenantId, ClientId);
        var web = builder.AddResource(new ProjectResource("web"));
        if (configureCredentialAfterReference)
        {
            web.WithReference(entra);
            entra.WithCertificateFromKeyVault(vault, "web-certificate");
        }
        else
        {
            entra.WithCertificateFromKeyVault(vault, "web-certificate");
            web.WithReference(entra);
        }
 
        var credential = Assert.IsType<EntraIdKeyVaultCertificateCredential>(Assert.Single(entra.Resource.ClientCredentials));
        Assert.Same(vault.Resource, credential.KeyVault);
        Assert.Null(credential.KeyVaultUrl);
        var dependencies = await web.Resource.GetResourceDependenciesAsync(
            builder.ExecutionContext, ResourceDependencyDiscoveryMode.DirectOnly);
        Assert.Contains(vault.Resource, dependencies);
        Assert.Single(dependencies);
 
        var environment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            web.Resource, operation, TestServiceProvider.Instance);
        var expectedVaultUrl = operation == DistributedApplicationOperation.Run
            ? "https://contoso.vault.usgovcloudapi.net/"
            : "{vault.outputs.vaultUri}";
        Assert.Equal(new Dictionary<string, string>
        {
            ["AzureAd__Instance"] = "https://login.microsoftonline.com/",
            ["AzureAd__TenantId"] = TenantId,
            ["AzureAd__ClientId"] = ClientId,
            ["AzureAd__ClientCredentials__0__SourceType"] = "KeyVault",
            ["AzureAd__ClientCredentials__0__KeyVaultUrl"] = expectedVaultUrl,
            ["AzureAd__ClientCredentials__0__KeyVaultCertificateName"] = "web-certificate"
        }, environment);
 
        var configuration = new ConfigurationBuilder().AddInMemoryCollection(environment.Select(pair =>
            new KeyValuePair<string, string?>(pair.Key.Replace("__", ":", StringComparison.Ordinal), pair.Value))).Build();
        var options = configuration.GetSection("AzureAd")
            .Get<MicrosoftEntraApplicationOptions>(binder => binder.ErrorOnUnknownConfiguration = true);
        Assert.NotNull(options);
        var boundCredential = Assert.Single(options.ClientCredentials!);
        Assert.Equal(CredentialSource.KeyVault, boundCredential.SourceType);
        Assert.Equal(operation == DistributedApplicationOperation.Run
            ? "https://contoso.vault.usgovcloudapi.net/"
            : "{vault.outputs.vaultUri}", boundCredential.KeyVaultUrl);
        Assert.Equal("web-certificate", boundCredential.KeyVaultCertificateName);
    }
 
    [Fact]
    public async Task ResourceCredential_UsesEmulatorEndpoint()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var vault = builder.AddAzureKeyVault("vault").WithAnnotation(new ContainerImageAnnotation { Image = "emulator" });
        vault.WithEndpoint("https", endpoint =>
        {
            endpoint.UriScheme = "https";
            endpoint.AllocatedEndpoint = new(endpoint, "localhost", 8443);
        });
        var entra = builder.AddExistingEntraApplication("entra", TenantId, ClientId)
            .WithCertificateFromKeyVault(vault, "web-certificate");
        var web = builder.AddResource(new ProjectResource("web")).WithReference(entra);
 
        var environment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            web.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        Assert.Equal("https://localhost:8443", environment["AzureAd__ClientCredentials__0__KeyVaultUrl"]);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public void Publish_ConsumersReceiveCertificateAccess(bool configureCredentialAfterReference)
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var vault = builder.AddAzureKeyVault("vault");
        var entra = builder.AddExistingEntraApplication("entra", TenantId, ClientId);
        var web = builder.AddResource(new ProjectResource("web"));
        if (configureCredentialAfterReference)
        {
            web.WithReference(entra);
            entra.WithCertificateFromKeyVault(vault, "web-certificate");
        }
        else
        {
            entra.WithCertificateFromKeyVault(vault, "web-certificate");
            web.WithReference(entra);
        }
 
        var annotation = Assert.Single(web.Resource.Annotations.OfType<RoleAssignmentAnnotation>());
        Assert.Same(vault.Resource, annotation.Target);
        var role = Assert.Single(annotation.Roles);
        Assert.Equal(KeyVaultBuiltInRole.KeyVaultCertificateUser.ToString(), role.Id);
    }
 
    [Fact]
    public void Publish_WithCredentialAlsoDeclaresCertificateAccess()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var vault = builder.AddAzureKeyVault("vault");
        var entra = builder.AddExistingEntraApplication("entra", TenantId, ClientId)
            .WithCredential(new EntraIdKeyVaultCertificateCredential
            {
                KeyVault = vault.Resource,
                CertificateNameInKeyVault = "web-certificate"
            });
        var web = builder.AddResource(new ProjectResource("web")).WithReference(entra);
 
        Assert.Same(vault.Resource,
            Assert.Single(web.Resource.Annotations.OfType<RoleAssignmentAnnotation>()).Target);
    }
 
    [Fact]
    public void Publish_ExplicitRoleAssignmentsOnConsumerAreRespected()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var vault = builder.AddAzureKeyVault("vault");
        var entra = builder.AddExistingEntraApplication("entra", TenantId, ClientId)
            .WithCertificateFromKeyVault(vault, "certificate");
        var web = builder.AddResource(new ProjectResource("web"))
            .WithRoleAssignments(vault, [])
            .WithReference(entra);
 
        var annotation = Assert.Single(web.Resource.Annotations.OfType<RoleAssignmentAnnotation>());
        Assert.Same(vault.Resource, annotation.Target);
        Assert.Empty(annotation.Roles);
    }
 
    [Fact]
    public void Publish_MultipleConsumersAndCertificatesDeduplicateAccess()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var vault = builder.AddAzureKeyVault("vault");
        var entra = builder.AddExistingEntraApplication("entra", TenantId, ClientId);
        var first = builder.AddResource(new ProjectResource("first")).WithReference(entra);
        var second = builder.AddResource(new ProjectResource("second")).WithReference(entra);
        entra.WithCertificateFromKeyVault(vault, "first-certificate")
            .WithCertificateFromKeyVault(vault, "second-certificate");
 
        foreach (var consumer in new[] { first.Resource, second.Resource })
        {
            var annotation = Assert.Single(consumer.Annotations.OfType<RoleAssignmentAnnotation>());
            Assert.Same(vault.Resource, annotation.Target);
            Assert.Equal(KeyVaultBuiltInRole.KeyVaultCertificateUser.ToString(), Assert.Single(annotation.Roles).Id);
        }
    }
 
    [Fact]
    public void Run_CertificateAccessExtendsTheVaultDefaultRolesOnce()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var vault = builder.AddAzureKeyVault("vault");
        var entra = builder.AddExistingEntraApplication("entra", TenantId, ClientId)
            .WithCertificateFromKeyVault(vault, "first-certificate")
            .WithCertificateFromKeyVault(vault, "second-certificate");
        var web = builder.AddResource(new ProjectResource("web")).WithReference(entra);
 
        var defaults = Assert.Single(vault.Resource.Annotations.OfType<DefaultRoleAssignmentsAnnotation>());
        Assert.Equal(
            [KeyVaultBuiltInRole.KeyVaultSecretsUser.ToString(), KeyVaultBuiltInRole.KeyVaultCertificateUser.ToString()],
            defaults.Roles.Select(r => r.Id).Order());
        Assert.Empty(web.Resource.Annotations.OfType<RoleAssignmentAnnotation>());
    }
 
    [Fact]
    public void Run_ClearedDefaultRoleAssignmentsAreNotRestored()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var vault = builder.AddAzureKeyVault("vault").ClearDefaultRoleAssignments();
        builder.AddExistingEntraApplication("entra", TenantId, ClientId)
            .WithCertificateFromKeyVault(vault, "certificate");
 
        Assert.Empty(vault.Resource.Annotations.OfType<DefaultRoleAssignmentsAnnotation>());
    }
 
    [Fact]
    public async Task Publish_EnvironmentWithoutRoleAssignmentSupportFails()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var vault = builder.AddAzureKeyVault("vault");
        var entra = builder.AddExistingEntraApplication("entra", TenantId, ClientId)
            .WithCertificateFromKeyVault(vault, "web-certificate");
        builder.AddContainer("web", "image").WithReference(entra);
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => PrepareAzureResourcesAsync(builder));
        Assert.Contains("does not support role assignments", exception.Message, StringComparison.Ordinal);
    }
 
    [Fact]
    public async Task Run_PlainExecutableConsumerGetsCertificateUserRole()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var vault = builder.AddAzureKeyVault("vault");
        var entra = builder.AddExistingEntraApplication("entra", TenantId, ClientId)
            .WithCertificateFromKeyVault(vault, "web-certificate");
        builder.AddExecutable("web", "node", ".").WithReference(entra);
 
        var roleAssignments = await PrepareAzureResourcesAsync(builder);
 
        Assert.Contains(roleAssignments, t => t.Contains(KeyVaultBuiltInRole.KeyVaultCertificateUser.ToString(), StringComparison.Ordinal));
    }
 
    private static async Task<string[]> PrepareAzureResourcesAsync(IDistributedApplicationBuilder builder)
    {
        using var app = builder.Build();
        await ExecuteBeforeStartHooksAsync(app, default);
 
        return app.Services.GetRequiredService<DistributedApplicationModel>()
            .Resources.OfType<AzureRoleAssignmentResource>()
            .Select(r => r.GetBicepTemplateString())
            .ToArray();
    }
 
    [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")]
    private static extern Task ExecuteBeforeStartHooksAsync(DistributedApplication app, CancellationToken cancellationToken);
 
    [Fact]
    public async Task ResourceCredential_RejectsInvalidInputs()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var vault = builder.AddAzureKeyVault("vault");
        var entra = builder.AddExistingEntraApplication("entra", TenantId, ClientId);
 
        Assert.Throws<ArgumentNullException>(() =>
            EntraIdResourceExtensions.WithCertificateFromKeyVault(null!, vault, "certificate"));
        Assert.Throws<ArgumentNullException>(() =>
            entra.WithCertificateFromKeyVault((IResourceBuilder<AzureKeyVaultResource>)null!, "certificate"));
        Assert.Throws<ArgumentException>(() => entra.WithCertificateFromKeyVault(vault, ""));
        Assert.Throws<ArgumentNullException>(() => entra.WithCertificateFromKeyVault(vault, null!));
        Assert.Empty(entra.Resource.ClientCredentials);
 
        entra.WithCredential(new EntraIdKeyVaultCertificateCredential
        {
            KeyVault = vault.Resource,
            KeyVaultUrl = "https://vault.vault.azure.net",
            CertificateNameInKeyVault = "certificate"
        });
        var web = builder.AddResource(new ProjectResource("web")).WithReference(entra);
 
        await Assert.ThrowsAsync<InvalidOperationException>(async () => await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            web.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance));
        Assert.Throws<InvalidOperationException>(() => new EntraIdKeyVaultCertificateCredential
        {
            CertificateNameInKeyVault = "certificate"
        }.SourceType);
    }
}