File: EntraIdProvisioningTests.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 ASPIREAZURE001 // Azure environment resources are experimental.
#pragma warning disable ASPIREAZURE003 // Tenant-scoped resource and managed identity annotations are experimental.
#pragma warning disable ASPIREPIPELINES001 // Verifies provisioning annotations on existing resources.
#pragma warning disable ASPIREPIPELINES003 // Exercises the deployment pipeline for existing registrations.
 
using Aspire.Hosting.ApplicationModel;
using System.Runtime.CompilerServices;
using Aspire.Hosting.Azure.EntraId.Provisioning;
using Aspire.Hosting.Dotnet;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Tests.Dcp;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using Aspire.Tests.Utils;
using Azure.Provisioning;
using Azure.Provisioning.KeyVault;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
 
namespace Aspire.Hosting.Azure.EntraId.Tests;
 
public class EntraIdProvisioningTests(ITestOutputHelper outputHelper)
{
    private const string TenantId = "aaaabbbb-0000-cccc-1111-dddd2222eeee";
    private const string ClientId = "00001111-aaaa-2222-bbbb-3333cccc4444";
 
    public static IEnumerable<object[]> RegistrationOnlyApiCases()
    {
        string[] apis =
        [
            "redirect-literal", "redirect-parameter", "redirect-endpoint", "redirect-host",
            "redirect-export-literal", "redirect-export-parameter", "redirect-export-endpoint", "redirect-export-host",
            "application-owner-literal", "application-owner-parameter", "application-owner-export-literal", "application-owner-export-parameter",
            "service-principal-owner-literal", "service-principal-owner-parameter", "service-principal-owner-export-literal", "service-principal-owner-export-parameter",
            "owners-literal", "owners-parameter", "owners-export-literal", "owners-export-parameter",
            "id-token", "role", "role-export", "default-uri", "default-uri-export", "local-certificate",
            "federation-consumer", "federation-compute", "federation-identity",
            "federation-export-consumer", "federation-export-compute", "federation-export-identity",
            "pre-authorization", "consent-api", "consent-external", "consent-external-export"
        ];
        foreach (var operation in new[] { DistributedApplicationOperation.Run, DistributedApplicationOperation.Publish })
        {
            foreach (var api in apis)
            {
                yield return [operation, api];
            }
        }
    }
 
    [Theory]
    [MemberData(nameof(RegistrationOnlyApiCases))]
    public void RegistrationOnlyApis_RejectExistingReceiversWithoutMutation(DistributedApplicationOperation operation, string api)
    {
        foreach (var fluent in new[] { false, true })
        {
            using var builder = TestDistributedApplicationBuilder.Create(operation);
            var entra = fluent
                ? builder.AddEntraIdApplication("entra").AsExistingApplication(TenantId, ClientId)
                : builder.AddExistingEntraApplication("entra", TenantId, ClientId);
            var configure = PrepareRegistrationOnlyApi(builder, entra, api);
            var annotations = entra.Resource.Annotations.ToArray();
            var templateParameters = entra.Resource.Parameters.ToArray();
            var permissions = entra.Resource.ApiAccess.SelectMany(a => a.Declared).ToArray();
 
            var exception = Assert.Throws<InvalidOperationException>(configure);
 
            Assert.StartsWith("Entra ID application 'entra' is an existing registration, and Aspire does not modify existing registrations. ", exception.Message);
            var remediation = api switch
            {
                var name when name.StartsWith("redirect", StringComparison.Ordinal) => "Configure web redirect URIs",
                var name when name.Contains("owner", StringComparison.Ordinal) => "owners",
                "id-token" => "Enable 'ID tokens",
                "role" or "role-export" => "Configure application roles",
                "default-uri" or "default-uri-export" => "Call WithApplicationIdUri with the URI",
                "local-certificate" => "Configure a certificate",
                var name when name.StartsWith("federation", StringComparison.Ordinal) => "then call WithFederatedManagedIdentityCredential with the identity's client ID",
                "pre-authorization" => "Configure pre-authorized client applications",
                _ => "Grant admin consent"
            };
            Assert.Contains(remediation, exception.Message);
            Assert.Equal(annotations, entra.Resource.Annotations);
            Assert.Equal(templateParameters, entra.Resource.Parameters);
            Assert.Equal(permissions, entra.Resource.ApiAccess.SelectMany(a => a.Declared));
            Assert.Empty(entra.Resource.RedirectUris);
            Assert.Empty(entra.Resource.RedirectUriParameters);
            Assert.Empty(entra.Resource.ApplicationOwners);
            Assert.Empty(entra.Resource.ServicePrincipalOwners);
            Assert.Empty(entra.Resource.AppRoles);
            Assert.Empty(entra.Resource.PreAuthorizedClients);
            Assert.Empty(entra.Resource.ApiAccess.SelectMany(a => a.Consented));
            Assert.Empty(entra.Resource.ClientCredentials);
            Assert.False(entra.Resource.EnableIdTokenIssuance);
            Assert.False(entra.Resource.UsesDefaultApplicationIdUri);
            Assert.False(entra.Resource.HasLocalDeveloperCertificateConfiguration);
            Assert.False(entra.Resource.HasPublishFederationConfiguration);
            Assert.Equal("api://existing", entra.Resource.ApplicationIdUri);
            Assert.True(entra.Resource.IsExistingApplication);
            Assert.Null(entra.Resource.ProvisioningTaskCompletionSource);
        }
    }
 
    [Theory]
    [MemberData(nameof(RegistrationOnlyApiCases))]
    public void AsExistingApplication_RejectsRegistrationOnlySettingsBeforeMutation(DistributedApplicationOperation operation, string api)
    {
        foreach (var conversion in new[] { "literal", "parameter", "export" })
        {
            using var builder = TestDistributedApplicationBuilder.Create(operation);
            var entra = builder.AddEntraIdApplication("entra");
            PrepareRegistrationOnlyApi(builder, entra, api)();
            var tenant = builder.AddParameter("tenant", TenantId);
            var client = builder.AddParameter("client", ClientId);
            var annotations = entra.Resource.Annotations.ToArray();
            var task = entra.Resource.ProvisioningTaskCompletionSource;
            entra.Resource.TenantId = "original.onmicrosoft.com";
            entra.Resource.ClientId = ClientId;
 
            var exception = Assert.Throws<InvalidOperationException>(() =>
            {
                _ = conversion switch
                {
                    "literal" => entra.AsExistingApplication(TenantId, ClientId),
                    "parameter" => entra.AsExistingApplication(tenant, client),
                    _ => entra.AsExistingApplicationForExport(tenant, ClientId)
                };
            });
 
            Assert.StartsWith("Entra ID application 'entra' cannot be converted to an existing registration", exception.Message);
            Assert.Contains("before calling AsExistingApplication", exception.Message);
            Assert.False(entra.Resource.IsExistingApplication);
            Assert.Same(task, entra.Resource.ProvisioningTaskCompletionSource);
            Assert.Equal(annotations, entra.Resource.Annotations);
            Assert.Equal("original.onmicrosoft.com", entra.Resource.TenantId);
            Assert.Equal(ClientId, entra.Resource.ClientId);
            Assert.Null(entra.Resource.TenantIdParameter);
            Assert.Null(entra.Resource.ClientIdParameter);
        }
    }
 
    private static Action PrepareRegistrationOnlyApi(
        IDistributedApplicationBuilder builder,
        IResourceBuilder<EntraIdApplicationResource> entra,
        string api)
    {
        var parameter = builder.AddParameter("value", ClientId);
        var targetApi = builder.AddExistingEntraApplication("target-api", TenantId, ClientId)
            .WithApplicationIdUri("api://target")
            .WithScope("read", "Read data.", scope => scope.Id = Guid.Parse(ClientId));
        entra.WithApplicationIdUri("api://existing")
            .WithScope("read", "Read data.", scope => scope.Id = Guid.Parse(ClientId))
            .WithApiPermissions(targetApi, "read")
            .WithApiPermissions(EntraIdApi.MicrosoftGraph, EntraIdApiPermission.Delegated("User.Read"));
        var consumer = builder.AddContainer("consumer", "image").WithHttpsEndpoint(targetPort: 8080).WithReference(entra);
        var endpoint = consumer.GetEndpoint("https");
        var identity = builder.AddAzureUserAssignedIdentity("identity");
 
        return () =>
        {
            _ = api switch
            {
                "redirect-literal" => entra.WithRedirectUris("https://example.com/signin"),
                "redirect-parameter" => entra.WithRedirectUris(parameter),
                "redirect-endpoint" => entra.WithRedirectUris(endpoint, "/signin"),
                "redirect-host" => entra.WithRedirectUris(parameter, "/signin"),
                "redirect-export-literal" => entra.WithRedirectUrisForExport("https://example.com/signin"),
                "redirect-export-parameter" => entra.WithRedirectUrisForExport(parameter),
                "redirect-export-endpoint" => entra.WithRedirectUrisForExport(endpoint, ["/signin"]),
                "redirect-export-host" => entra.WithRedirectUrisForExport(parameter, ["/signin"]),
                "application-owner-literal" => entra.WithApplicationOwners(ClientId),
                "application-owner-parameter" => entra.WithApplicationOwners(parameter),
                "application-owner-export-literal" => entra.WithApplicationOwnersForExport(ClientId),
                "application-owner-export-parameter" => entra.WithApplicationOwnersForExport(parameter),
                "service-principal-owner-literal" => entra.WithServicePrincipalOwners(ClientId),
                "service-principal-owner-parameter" => entra.WithServicePrincipalOwners(parameter),
                "service-principal-owner-export-literal" => entra.WithServicePrincipalOwnersForExport(ClientId),
                "service-principal-owner-export-parameter" => entra.WithServicePrincipalOwnersForExport(parameter),
                "owners-literal" => entra.WithOwners(ClientId),
                "owners-parameter" => entra.WithOwners(parameter),
                "owners-export-literal" => entra.WithOwnersForExport(ClientId),
                "owners-export-parameter" => entra.WithOwnersForExport(parameter),
                "id-token" => entra.WithIdTokenIssuance(),
                "role" => entra.WithAppRole(new EntraIdAppRole("Reader", "Reader", "Read data.")),
                "role-export" => entra.WithAppRoleForExport("Reader", "Reader", "Read data."),
                "default-uri" => entra.WithApplicationIdUri(),
                "default-uri-export" => entra.WithApplicationIdUriForExport(),
                "local-certificate" => entra.RunWithLocalDeveloperCertificateCredential(),
                "federation-consumer" => entra.PublishWithFederatedManagedIdentityCredential(),
                "federation-compute" => entra.PublishWithFederatedManagedIdentityCredential(consumer),
                "federation-identity" => entra.PublishWithFederatedManagedIdentityCredential(identity),
                "federation-export-consumer" => entra.PublishWithFederatedManagedIdentityCredentialForExport(),
                "federation-export-compute" => entra.PublishWithFederatedManagedIdentityCredentialForExport(consumer),
                "federation-export-identity" => entra.PublishWithFederatedManagedIdentityCredentialForExport(identity),
                "pre-authorization" => entra.WithPreAuthorizedApplication(targetApi, "read"),
                "consent-api" => entra.WithAdminConsent(targetApi, "read"),
                "consent-external" => entra.WithAdminConsent(EntraIdApi.MicrosoftGraph, EntraIdApiPermission.Delegated("User.Read")),
                "consent-external-export" => entra.WithExternalApiAdminConsentForExport(EntraIdWellKnownApi.MicrosoftGraph, [new() { Name = "User.Read" }]),
                _ => throw new ArgumentOutOfRangeException(nameof(api))
            };
        };
    }
 
    [Fact]
    public async Task KeyVaultCertificate_RunAssignsDeveloperCertificateAccess()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var vault = builder.AddAzureKeyVault("vault");
        var entra = builder.AddExistingEntraApplication("entra", TenantId, ClientId)
            .WithCertificateFromKeyVault(vault, "web-certificate");
        builder.AddContainer("web", "image").WithReference(entra);
 
        using var app = builder.Build();
        await ExecuteBeforeStartHooksAsync(app, default);
 
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var assignment = Assert.Single(model.Resources.OfType<AzureRoleAssignmentResource>(),
            r => r.TargetAzureResource == vault.Resource);
        await Verify(assignment.GetBicepTemplateString(), "bicep");
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task KeyVaultCertificate_PublishAssignsConsumerIdentityAndCertificateAccess(bool configureCredentialAfterReference)
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        builder.AddAzureContainerAppEnvironment("env");
        var vault = builder.AddAzureKeyVault("vault").PublishAsExisting("existing-vault", resourceGroup: null);
        var entra = builder.AddExistingEntraApplication("entra", TenantId, ClientId);
        var web = builder.AddContainer("web", "image");
        if (configureCredentialAfterReference)
        {
            web.WithReference(entra);
            entra.WithCertificateFromKeyVault(vault, "web-certificate");
        }
        else
        {
            entra.WithCertificateFromKeyVault(vault, "web-certificate");
            web.WithReference(entra);
        }
 
        using var app = builder.Build();
        await ExecuteBeforeStartHooksAsync(app, default);
 
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var identity = Assert.Single(model.Resources.OfType<AzureUserAssignedIdentityResource>());
        Assert.Equal("web-identity", identity.Name);
        var roles = web.Resource.Annotations.OfType<RoleAssignmentAnnotation>()
            .Where(a => a.Target == vault.Resource).SelectMany(a => a.Roles);
        Assert.Contains(roles, role => role.Id == KeyVaultBuiltInRole.KeyVaultCertificateUser.ToString());
        var assignment = Assert.Single(model.Resources.OfType<AzureRoleAssignmentResource>(),
            r => r.TargetAzureResource == vault.Resource);
        await Verify(assignment.GetBicepTemplateString(), "bicep").UseParameters(configureCredentialAfterReference);
    }
 
    [Fact]
    public async Task WithOwners_GeneratesOwnerRelationships()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var spOwner = builder.AddParameter("sp-owner");
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithApplicationOwners("00001111-aaaa-2222-bbbb-3333cccc4444", "00001111-AAAA-2222-bbbb-3333cccc4444")
            .WithServicePrincipalOwners("11112222-aaaa-3333-bbbb-4444cccc5555")
            .WithServicePrincipalOwners(spOwner);
 
        await Verify(entra.Resource.GetBicepTemplateString(), "bicep");
    }
 
    [Fact]
    public async Task WithAppRole_GeneratesAppRolesWithStableIds()
    {
        static string Generate()
        {
            using var builder = TestDistributedApplicationBuilder.Create();
            var entra = builder.AddEntraIdApplication("entra-api")
                .WithAppRole(new EntraIdAppRole("User", "User", "Can use the API."))
                .WithAppRole(new EntraIdAppRole("Admin", "Administrator", "Can administer the API.", ["User", "Application"]))
                .WithAppRole(new EntraIdAppRole("Reader", "Reader", "Can read data.", ["Application"], new Guid("11112222-aaaa-3333-bbbb-4444cccc5555")))
                .WithAppRole(new EntraIdAppRole("Retired", "Retired", "Being removed.", isEnabled: false));
            return entra.Resource.GetBicepTemplateString();
        }
 
        var bicep = Generate();
        Assert.Equal(bicep, Generate());
        await Verify(bicep, "bicep");
    }
 
    [Fact]
    public void WithAppRole_ValidatesArguments()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-api").WithAppRole(new EntraIdAppRole("Admin", "Admin", "Admin role."));
 
        Assert.Throws<ArgumentException>(() => entra.WithAppRole(new EntraIdAppRole("Admin", "Other", "Duplicate value.")));
        Assert.Throws<ArgumentException>(() => entra.WithAppRole(new EntraIdAppRole("Has Space", "Name", "Description.")));
        Assert.Throws<ArgumentException>(() => entra.WithAppRole(new EntraIdAppRole("Other", "Name", "Description.", [])));
        Assert.Throws<ArgumentException>(() => entra.WithAppRole(new EntraIdAppRole("Other", "Name", "Description.", id: Guid.Empty)));
        Assert.Throws<ArgumentException>(() => entra.WithAppRole(new EntraIdAppRole("Other", " ", "Description.")));
        Assert.Single(entra.Resource.AppRoles);
    }
 
    [Theory]
    [InlineData(".Admin")]
    [InlineData("Admin\"")]
    [InlineData("Ad min")]
    [InlineData("Adm\u00efn")]
    [InlineData("Admin\n")]
    public void AppRole_RejectsValuesThatGraphRejects(string value)
    {
        Assert.Throws<ArgumentException>(() => new EntraIdAppRole(value, "Name", "Description."));
    }
 
    [Theory]
    [InlineData("Task.Write")]
    [InlineData("Admin:Read-Only_2")]
    [InlineData("a!#$%&'()*+,-/:;<=>?@[]^_`{|}~")]
    public void AppRole_AcceptsValuesThatGraphAllows(string value)
    {
        Assert.Equal(value, new EntraIdAppRole(value, "Name", "Description.").Value);
    }
 
    [Fact]
    public void AppRole_RejectsValuesLongerThanGraphAllows()
    {
        Assert.Throws<ArgumentException>(() => new EntraIdAppRole(new string('a', 121), "Name", "Description."));
        Assert.Equal(120, new EntraIdAppRole(new string('a', 120), "Name", "Description.").Value.Length);
    }
 
    [Fact]
    public void AppRoles_CannotBeMutatedThroughTheCollection()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-api").WithAppRole(new EntraIdAppRole("Admin", "Admin", "Admin role."));
 
        var roles = Assert.IsAssignableFrom<IList<EntraIdAppRole>>(entra.Resource.AppRoles);
        Assert.Throws<NotSupportedException>(() => roles.Add(new EntraIdAppRole("Other", "Other", "Other role.")));
        Assert.Throws<NotSupportedException>(roles.Clear);
        Assert.Single(entra.Resource.AppRoles);
    }
 
    [Fact]
    public void WithAppRole_RejectsDuplicateRoleIds()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var id = Guid.NewGuid();
        var entra = builder.AddEntraIdApplication("entra-api").WithAppRole(new EntraIdAppRole("A", "A", "A role.", id: id));
 
        Assert.Throws<ArgumentException>(() => entra.WithAppRole(new EntraIdAppRole("B", "B", "B role.", id: id)));
    }
 
    [Fact]
    public void WithAppRole_ExistingApplication_Throws()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddExistingEntraApplication("entra-api", "00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002");
 
        Assert.Throws<InvalidOperationException>(() => entra.WithAppRole(new EntraIdAppRole("Admin", "Admin", "Admin role.")));
    }
 
    [Fact]
    public void WithOwners_ValidatesIds()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-web");
 
        Assert.Throws<ArgumentException>(() => entra.WithApplicationOwners("not-a-guid"));
        Assert.Throws<ArgumentException>(() => entra.WithServicePrincipalOwners(" "));
        Assert.Empty(entra.Resource.ApplicationOwners);
    }
 
    [Fact]
    public async Task WithOwners_SetsApplicationAndServicePrincipalOwners()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var param = builder.AddParameter("owner");
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithOwners("00001111-aaaa-2222-bbbb-3333cccc4444")
            .WithOwners(param);
 
        Assert.Equal(2, entra.Resource.ApplicationOwners.Count);
        Assert.Equal(entra.Resource.ApplicationOwners, entra.Resource.ServicePrincipalOwners);
        await Verify(entra.Resource.GetBicepTemplateString(), "bicep");
    }
 
    [Fact]
    public void WithOwners_NormalizesAndDeduplicatesGuidFormats()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithApplicationOwners(
                "00001111-AAAA-2222-bbbb-3333cccc4444",
                "00001111aaaa2222bbbb3333cccc4444",
                "{00001111-aaaa-2222-bbbb-3333cccc4444}");
 
        Assert.Equal(["00001111-aaaa-2222-bbbb-3333cccc4444"], entra.Resource.ApplicationOwners);
    }
 
    [Fact]
    public void DefaultProvisioning_HasNoOwners()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-web");
 
        Assert.DoesNotContain("owners", entra.Resource.GetBicepTemplateString());
    }
 
    [Fact]
    public async Task WithFederatedManagedIdentityCredential_Identity_GeneratesFederatedCredential()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var identity = builder.AddAzureUserAssignedIdentity("web-identity");
        var entra = builder.AddEntraIdApplication("entra-web")
            .PublishWithFederatedManagedIdentityCredential(identity);
 
        await Verify(entra.Resource.GetBicepTemplateString(), "bicep");
    }
 
    [Fact]
    public async Task WithFederatedManagedIdentityCredential_ConsumerIdentity_GeneratesCredentialPerConsumer()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var webIdentity = builder.AddAzureUserAssignedIdentity("web-identity");
        var apiIdentity = builder.AddAzureUserAssignedIdentity("api-identity");
        var entra = builder.AddEntraIdApplication("entra-web")
            .PublishWithFederatedManagedIdentityCredential();
        builder.AddResource(new ProjectResource("web")).WithAzureUserAssignedIdentity(webIdentity).WithReference(entra);
        builder.AddResource(new ProjectResource("api")).WithAzureUserAssignedIdentity(apiIdentity).WithReference(entra);
 
        await Verify(entra.Resource.GetBicepTemplateString(), "bicep");
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task WithFederatedManagedIdentityCredential_ConsumerIdentity_EmitsClientIdOutputInPublishMode(bool useDotnetProject)
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var identity = builder.AddAzureUserAssignedIdentity("web-identity");
        var entra = builder.AddEntraIdApplication("entra-web")
            .PublishWithFederatedManagedIdentityCredential();
#pragma warning disable CS0618 // Federated credentials must use the assigned consumer identity through either factory.
        IResourceBuilder<IResourceWithEnvironment> web = useDotnetProject
            ? builder.AddDotnetProject("web", "web.csproj", options => options.ExcludeLaunchProfile = true)
                .WithAzureUserAssignedIdentity(identity)
            : builder.AddProject("web", "web.csproj", options => options.ExcludeLaunchProfile = true)
                .WithAzureUserAssignedIdentity(identity);
#pragma warning restore CS0618
        web.WithReference(entra);
 
        var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            web.Resource, DistributedApplicationOperation.Publish, TestServiceProvider.Instance);
 
        Assert.Equal("{web-identity.outputs.clientId}", env["AzureAd__ClientCredentials__0__ManagedIdentityClientId"]);
        Assert.Equal("SignedAssertionFromManagedIdentity", env["AzureAd__ClientCredentials__0__SourceType"]);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public void ValidateManagedIdentities_RejectsResourceWithoutIdentity(bool useDotnetProject)
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var identity = builder.AddAzureUserAssignedIdentity("web-identity");
        var entra = builder.AddEntraIdApplication("entra-web")
            .PublishWithFederatedManagedIdentityCredential(identity);
#pragma warning disable CS0618 // Identity validation must not silently skip either project resource type.
        IResourceBuilder<IResourceWithEnvironment> web = useDotnetProject
            ? builder.AddDotnetProject("web", "web.csproj", options => options.ExcludeLaunchProfile = true)
            : builder.AddProject("web", "web.csproj", options => options.ExcludeLaunchProfile = true);
#pragma warning restore CS0618
        web.WithReference(entra);
 
        var exception = Assert.Throws<InvalidOperationException>(entra.Resource.ValidateManagedIdentities);
        Assert.Contains("no user-assigned managed identity", exception.Message);
    }
 
    [Fact]
    public async Task ValidateManagedIdentities_RejectsResourceUsingAnotherIdentity()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var trusted = builder.AddAzureUserAssignedIdentity("trusted-identity");
        var other = builder.AddAzureUserAssignedIdentity("other-identity");
        var entra = builder.AddEntraIdApplication("entra-web")
            .PublishWithFederatedManagedIdentityCredential(trusted);
        var web = builder.AddResource(new ProjectResource("web")).WithAzureUserAssignedIdentity(other).WithReference(entra);
 
        Assert.Contains("WithAzureUserAssignedIdentity", Assert.Throws<InvalidOperationException>(entra.Resource.ValidateManagedIdentities).Message);
        // The identity that was passed in is used as the value without looking the consumer up.
        var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            web.Resource, DistributedApplicationOperation.Publish, TestServiceProvider.Instance);
        Assert.Equal("{trusted-identity.outputs.clientId}", env["AzureAd__ClientCredentials__0__ManagedIdentityClientId"]);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task PublishWithFederatedManagedIdentityCredential_EnvironmentCallbackRunBeforeIdentityExists_StillResolvesIt(bool useDotnetProject)
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var entra = builder.AddEntraIdApplication("entra-web")
            .PublishWithFederatedManagedIdentityCredential();
#pragma warning disable CS0618 // Cached dependency discovery must preserve late identity resolution for both factories.
        IResourceBuilder<IResourceWithEnvironment> web = useDotnetProject
            ? builder.AddDotnetProject("web", "web.csproj", options => options.ExcludeLaunchProfile = true)
            : builder.AddProject("web", "web.csproj", options => options.ExcludeLaunchProfile = true);
#pragma warning restore CS0618
        web.WithReference(entra);
 
        // The Azure resource preparer runs the environment callbacks to discover dependencies before it attaches
        // identities, and a callback's result is cached, so the value must not need the identity at that point.
        var dependencies = await web.Resource.GetResourceDependenciesAsync(builder.ExecutionContext, ResourceDependencyDiscoveryMode.DirectOnly);
        Assert.Contains(entra.Resource, dependencies);
 
        var identity = builder.AddAzureUserAssignedIdentity("web-identity");
        web.WithAnnotation(new AppIdentityAnnotation(identity.Resource));
        var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            web.Resource, DistributedApplicationOperation.Publish, TestServiceProvider.Instance);
        Assert.Equal("{web-identity.outputs.clientId}", env["AzureAd__ClientCredentials__0__ManagedIdentityClientId"]);
    }
    [Fact]
    public async Task BeforeStart_ComputeEnvironmentWithIdentities_TrustsTheIdentityTheAzurePreparerCreated()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        builder.AddAzureContainerAppEnvironment("env");
        var entra = builder.AddEntraIdApplication("entra-web")
            .PublishWithFederatedManagedIdentityCredential();
        builder.AddContainer("web", "image").WithReference(entra);
 
        using var app = builder.Build();
        // Pipeline resolution reads the template before the Azure resource preparer attaches the identities.
        Assert.DoesNotContain("web_identity_principalId", entra.Resource.GetBicepTemplateString());
        await ExecuteBeforeStartHooksAsync(app, default);
 
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var identity = Assert.Single(model.Resources.OfType<AzureUserAssignedIdentityResource>());
        Assert.Equal("web-identity", identity.Name);
        Assert.Same(identity, Assert.Single(entra.Resource.GetFederatedIdentities()));
        Assert.Contains("web_identity_principalId", entra.Resource.GetBicepTemplateString());
    }
 
    [Fact]
    public async Task BeforeStart_ComputeEnvironmentWithoutIdentities_FailsWithTheResourceName()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var entra = builder.AddEntraIdApplication("entra-web")
            .PublishWithFederatedManagedIdentityCredential();
        builder.AddContainer("web", "image").WithReference(entra);
 
        using var app = builder.Build();
        var exception = await Assert.ThrowsAnyAsync<Exception>(() => ExecuteBeforeStartHooksAsync(app, default));
        Assert.Contains("Resource 'web'", exception.ToString());
        Assert.Contains("no user-assigned managed identity", exception.ToString());
    }
    [Fact]
    public void PublishWithFederatedManagedIdentityCredential_Identity_DoesNothingWhenRunning()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var identity = builder.AddAzureUserAssignedIdentity("web-identity");
        var entra = builder.AddEntraIdApplication("entra-web")
            .PublishWithFederatedManagedIdentityCredential(identity);
 
        // Managed identities exist only in Azure, and when running the identity is not part of the model. A federated
        // credential would make the registration's Bicep reference an identity that is never provisioned.
        Assert.Empty(entra.Resource.ClientCredentials);
        var bicep = entra.Resource.GetBicepTemplateString();
        Assert.DoesNotContain("federatedIdentityCredentials", bicep);
        Assert.DoesNotContain("principalId", bicep);
    }
 
    [Fact]
    public void WithCredential_ConsumerIdentityCredential_GeneratesNoTrustWhenRunning()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithCredential(new EntraIdFederatedIdentityCredential(ClientId));
        var web = builder.AddResource(new ProjectResource("web")).WithReference(entra);
 
        // A raw credential cannot name an identity resource, so the registration's Bicep cannot reference an identity
        // that is not provisioned when running.
        var bicep = entra.Resource.GetBicepTemplateString();
        Assert.DoesNotContain("federatedIdentityCredentials", bicep);
        Assert.DoesNotContain("principalId", bicep);
        Assert.Empty(entra.Resource.GetFederatedIdentities());
    }
 
    [Fact]
    public void PublishWithFederatedManagedIdentityCredential_Identity_AddsTheCredentialWhenPublishing()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var identity = builder.AddAzureUserAssignedIdentity("web-identity");
        var entra = builder.AddEntraIdApplication("entra-web")
            .PublishWithFederatedManagedIdentityCredential(identity);
        var web = builder.AddResource(new ProjectResource("web"));
 
        var credential = Assert.Single(entra.Resource.ClientCredentials);
        var envVars = new Dictionary<string, object>();
        Assert.True(credential.EmitEnvironmentVariables(envVars, "AzureAd__ClientCredentials__0", "__", entra.Resource, web.Resource, builder.ExecutionContext));
        Assert.NotEmpty(envVars);
    }
 
    [Theory]
    [InlineData(1)]
    [InlineData(2)]
    [InlineData(64)]
    public void FederatedCredentialName_IsPrefixedSoItFitsGraphsLengthLimits(int identityNameLength)
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var identityName = new string('i', identityNameLength);
        var identity = builder.AddAzureUserAssignedIdentity(identityName);
        var entra = builder.AddEntraIdApplication(new string('e', 64))
            .PublishWithFederatedManagedIdentityCredential(identity);
 
        // Microsoft Graph requires a federated credential's name to be 3-120 characters. Prefixing the registration's name
        // gives 129 characters for two valid 64-character names, and an unprefixed one- or two-character identity name is too short.
        var credentialName = $"aspire-{identityName}";
        Assert.InRange(credentialName.Length, 3, 120);
        var bicep = entra.Resource.GetBicepTemplateString();
        Assert.Contains($"/{credentialName}'", bicep);
        Assert.DoesNotContain($"{new string('e', 64)}-{identityName}", bicep);
    }
 
    [Theory]
    [InlineData("https://login.microsoftonline.com/", "https://login.microsoftonline.com")]
    [InlineData("https://LOGIN.MICROSOFTONLINE.COM/", "https://login.microsoftonline.com")]
    [InlineData("https://login.microsoftonline.com:443/", "https://login.microsoftonline.com")]
    [InlineData("https://Login.MicrosoftOnline.US:443", "https://login.microsoftonline.us")]
    [InlineData("https://login.contoso.example:8443/", "https://login.contoso.example:8443")]
    public void GetIssuerPrefix_NormalizesTheHostAndDropsTheDefaultPort(string instance, string expected)
    {
        Assert.Equal(expected, EntraIdFederation.GetIssuerPrefix(instance));
    }
 
    [Fact]
    public void FederatedCredentialIssuer_UsesTheNormalizedInstance()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var identity = builder.AddAzureUserAssignedIdentity("web-identity");
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithInstance("https://LOGIN.MICROSOFTONLINE.COM:443/")
            .PublishWithFederatedManagedIdentityCredential(identity);
 
        // The issuer must equal the token's iss claim exactly.
        Assert.Contains("issuer: 'https://login.microsoftonline.com/${tenant().tenantId}/v2.0'", entra.Resource.GetBicepTemplateString());
    }
 
    [Fact]
    public void FederatedCredentialPrincipalIdParameter_DoesNotCollideWithOtherParameters()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var redirectUris = builder.AddParameter("web-identity-principalId");
        var webIdentity = builder.AddAzureUserAssignedIdentity("web-identity");
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithRedirectUris(redirectUris)
            .PublishWithFederatedManagedIdentityCredential(webIdentity);
 
        var bicep = entra.Resource.GetBicepTemplateString();
 
        // The redirect URI parameter keeps its value, and the identity gets its own parameter.
        Assert.Same(redirectUris.Resource, entra.Resource.Parameters["web_identity_principalId"]);
        Assert.Same(webIdentity.Resource, Assert.IsType<BicepOutputReference>(entra.Resource.Parameters["web_identity_principalId_2"]).Resource);
        Assert.Contains("subject: web_identity_principalId_2", bicep);
 
        // Generating the template again must give the same names.
        Assert.Equal(bicep, entra.Resource.GetBicepTemplateString());
    }
 
    [Fact]
    public void FederatedCredentialPrincipalIdParameter_DoesNotCollideWithParametersThatDifferOnlyInCasing()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        // Bicep rejects parameters that differ only in casing (BCP353).
        var redirectUris = builder.AddParameter("WEB-identity-principalId");
        var webIdentity = builder.AddAzureUserAssignedIdentity("web-identity");
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithRedirectUris(redirectUris)
            .PublishWithFederatedManagedIdentityCredential(webIdentity);
 
        var bicep = entra.Resource.GetBicepTemplateString();
 
        Assert.Same(redirectUris.Resource, entra.Resource.Parameters["WEB_identity_principalId"]);
        Assert.Same(webIdentity.Resource, Assert.IsType<BicepOutputReference>(entra.Resource.Parameters["web_identity_principalId_2"]).Resource);
        Assert.Contains("subject: web_identity_principalId_2", bicep);
    }
 
    [Fact]
    public void FederatedCredentialResourceIdentifier_DoesNotCollideWithParameters()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        // Parameters and resources share one namespace, and this parameter normalizes to the credential's resource identifier.
        var redirectUris = builder.AddParameter("federatedCredential-web-identity");
        var webIdentity = builder.AddAzureUserAssignedIdentity("web-identity");
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithRedirectUris(redirectUris)
            .PublishWithFederatedManagedIdentityCredential(webIdentity);
 
        var bicep = entra.Resource.GetBicepTemplateString();
 
        Assert.Contains("param federatedCredential_web_identity string", bicep);
        Assert.Contains("resource federatedCredential_web_identity_2 ", bicep);
    }
 
    [Fact]
    public void ValidateManagedIdentities_IgnoresResourcesExcludedFromTheManifest()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var identity = builder.AddAzureUserAssignedIdentity("web-identity");
        var entra = builder.AddEntraIdApplication("entra-web")
            .PublishWithFederatedManagedIdentityCredential();
        var published = builder.AddResource(new ProjectResource("web")).WithAzureUserAssignedIdentity(identity).WithReference(entra);
        builder.AddResource(new ProjectResource("local-only")).ExcludeFromManifest().WithReference(entra);
 
        // The excluded resource is not deployed, so the Azure resource preparer never gives it an identity.
        entra.Resource.ValidateManagedIdentities();
        Assert.Same(identity.Resource, Assert.Single(entra.Resource.GetFederatedIdentities()));
        Assert.NotNull(published.Resource);
    }
    [Theory]
    [InlineData("https://login.microsoftonline.com/", "api://AzureADTokenExchange")]
    [InlineData("https://login.microsoftonline.us/", "api://AzureADTokenExchangeUSGov")]
    [InlineData("https://login.partner.microsoftonline.cn/", "api://AzureADTokenExchangeChina")]
    [InlineData("https://login.chinacloudapi.cn/", "api://AzureADTokenExchangeChina")]
    [InlineData("https://LOGIN.CHINACLOUDAPI.CN:443/", "api://AzureADTokenExchangeChina")]
    public void GetTokenExchangeAudience_MapsSupportedClouds(string instance, string expected)
    {
        Assert.Equal(expected, EntraIdFederation.GetTokenExchangeAudience(instance));
    }
 
    [Fact]
    public void WithFederatedManagedIdentityCredential_Identity_RejectsUnsupportedCloud()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var identity = builder.AddAzureUserAssignedIdentity("web-identity");
        var entra = builder.AddEntraIdApplication("entra-web")
            .PublishWithFederatedManagedIdentityCredential(identity);
        entra.Resource.Instance = "https://login.contoso.example/";
 
        var exception = Assert.Throws<InvalidOperationException>(entra.Resource.GetBicepTemplateString);
        Assert.Contains("no known managed identity token exchange audience", exception.Message);
    }
 
    [Fact]
    public async Task AddEntraIdApplication_GeneratesGraphBicep()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-web");
 
        Assert.IsAssignableFrom<AzureProvisioningResource>(entra.Resource);
        var tenantScoped = Assert.Single(entra.Resource.Annotations.OfType<TenantScopedResourceAnnotation>());
        Assert.Equal(new Dictionary<string, string>
        {
            ["Microsoft.Graph/applications"] = "objectId",
            ["Microsoft.Graph/servicePrincipals"] = "servicePrincipalId"
        }, tenantScoped.ResourceIdOutputs);
        Assert.NotNull(entra.Resource.ProvisioningTaskCompletionSource);
        Assert.False(entra.Resource.ProvisioningTaskCompletionSource.Task.IsCompleted);
        Assert.Equal(new[] { "tenantResourceSuffix" }, entra.Resource.Parameters.Keys);
        Assert.Equal(EntraIdSignInAudience.AzureADMyOrg, entra.Resource.SignInAudience);
        Assert.Null(entra.Resource.Parameters[AzureBicepResource.KnownParameters.TenantResourceSuffix]);
        Assert.Single(builder.Resources.OfType<AzureEnvironmentResource>());
 
        await Verify(entra.Resource.GetBicepTemplateString(), "bicep");
        Assert.Equal(new[] { "tenantResourceSuffix" }, entra.Resource.Parameters.Keys);
    }
 
    [Fact]
    public async Task ConfigureInfrastructure_CustomizesGraphResources()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-web")
            .ConfigureInfrastructure(infrastructure =>
            {
                var application = Assert.Single(infrastructure.GetProvisionableResources().OfType<MicrosoftGraphApplication>());
                var servicePrincipal = Assert.Single(infrastructure.GetProvisionableResources().OfType<MicrosoftGraphServicePrincipal>());
                application.DisplayName = "Contoso's web application";
                application.RedirectUris = ["https://localhost:7123/signin-oidc", "https://localhost:7123/auth/callback"];
                application.EnableIdTokenIssuance = true;
                Assert.Equal("application.appId", servicePrincipal.AppId.Compile().ToString());
                infrastructure.Add(new ProvisioningOutput("applicationDisplayName", typeof(string))
                {
                    Value = application.DisplayName.ToBicepExpression()
                });
            });
 
        await Verify(entra.Resource.GetBicepTemplateString(), "bicep");
    }
 
    [Fact]
    public void GetBicepTemplateFile_IsIdempotent()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithRedirectUris("https://localhost:7123/signin-oidc");
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        using var first = entra.Resource.GetBicepTemplateFile(workspace.Path);
        var firstContents = File.ReadAllText(first.Path);
        using var second = entra.Resource.GetBicepTemplateFile(workspace.Path);
        var secondContents = File.ReadAllText(second.Path);
 
        Assert.Equal(firstContents, secondContents);
        Assert.Equal(firstContents, entra.Resource.GetBicepTemplateString());
    }
 
    [Fact]
    public async Task ConfigureInfrastructure_CustomizesApplicationMetadata()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var serviceReference = builder.AddParameter("service-reference");
        var entra = builder.AddEntraIdApplication("entra-api")
            .WithScope("read", "Read data.")
            .ConfigureInfrastructure(infrastructure =>
            {
                var application = infrastructure.GetProvisionableResources().OfType<MicrosoftGraphApplication>()
                    .Single(r => r.BicepIdentifier == "application");
                application.Tags.Add("department:engineering");
                application.Tags.Add(serviceReference.AsProvisioningParameter(infrastructure));
                application.ServiceManagementReference = serviceReference.AsProvisioningParameter(infrastructure);
                application.Description = "Orders API";
                application.GroupMembershipClaims = "SecurityGroup";
                application.HomePageUrl = "https://contoso.example";
                application.SpaRedirectUris.Add("https://contoso.example/spa");
 
                var servicePrincipal = Assert.Single(infrastructure.GetProvisionableResources().OfType<MicrosoftGraphServicePrincipal>());
                servicePrincipal.Tags.Add("department:engineering");
                servicePrincipal.AppRoleAssignmentRequired = true;
                servicePrincipal.NotificationEmailAddresses.Add("owners@contoso.example");
            });
 
        var bicep = entra.Resource.GetBicepTemplateString();
        Assert.Same(serviceReference.Resource, entra.Resource.Parameters["service_reference"]);
        Assert.Equal(bicep, entra.Resource.GetBicepTemplateString());
        await Verify(bicep, "bicep");
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public void GetBicepTemplateString_RejectsNonResourceGroupScope(bool tenantScoped)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-web");
        entra.Resource.Scope = tenantScoped
            ? AzureBicepResourceScope.CreateForTenant()
            : AzureBicepResourceScope.CreateForSubscription("11111111-2222-3333-4444-555555555555");
 
        var exception = Assert.Throws<InvalidOperationException>(entra.Resource.GetBicepTemplateString);
        Assert.Equal("Entra ID application templates must be deployed at resource-group scope.", exception.Message);
    }
 
    [Fact]
    public async Task AddEntraIdApplication_PublishesManifestWithParameters()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithSignInAudience(EntraIdSignInAudience.AzureADMultipleOrgs)
            .WithRedirectUris("https://localhost:7123/signin-oidc")
            .WithIdTokenIssuance();
 
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var manifest = await ManifestUtils.GetManifest(entra.Resource, workspace.Path);
        Assert.Equal(new[] { "tenantResourceSuffix" }, entra.Resource.Parameters.Keys);
        await Verify(manifest.ToJsonString(), "json")
            .AppendContentAsFile(entra.Resource.GetBicepTemplateString(), "bicep");
    }
 
    [Fact]
    public async Task Publish_SignInOnlyRegistration_RequiresExplicitIdTokenIssuance()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputHelper, workspace.Path);
        var web = builder.AddEntraIdApplication("entra-web")
            .WithRedirectUris(
                "https://localhost:7123/signin-oidc",
                "https://localhost:7123/signout-callback-oidc")
            .WithIdTokenIssuance();
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var environment = model.Resources.OfType<AzureEnvironmentResource>().Single();
        var options = app.Services.GetRequiredService<IOptions<AzureProvisioningOptions>>().Value;
        await using var step = await new TestPipelineActivityReporter(outputHelper).CreateStepAsync("Publishing sign-in-only registration");
        var context = new AzurePublishingContext(
            workspace.Path, options, app.Services, NullLogger<AzurePublishingContext>.Instance, step);
 
        await context.WriteModelAsync(model, environment);
 
        await Verify(await File.ReadAllTextAsync(Path.Combine(workspace.Path, "main.bicep")), "bicep")
            .AppendContentAsFile(await File.ReadAllTextAsync(Path.Combine(workspace.Path, "entra-web", "entra-web.bicep")), "bicep");
    }
 
    [Fact]
    public async Task Publish_TenantResourceSuffix_IsADeploymentParameterWithExplicitOverrides()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputHelper, workspace.Path);
        builder.Configuration["Azure:TenantResourceSuffix"] = "local-developer";
        builder.AddEntraIdApplication("entra-web");
        builder.AddEntraIdApplication("entra-api")
            .WithParameter(AzureBicepResource.KnownParameters.TenantResourceSuffix, "production");
        builder.AddExistingEntraApplication("existing", tenantId: TenantId, clientId: ClientId);
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var environment = Assert.Single(model.Resources.OfType<AzureEnvironmentResource>());
        await using var step = await new TestPipelineActivityReporter(outputHelper).CreateStepAsync("Publishing tenant suffix");
        var context = new AzurePublishingContext(
            workspace.Path,
            app.Services.GetRequiredService<IOptions<AzureProvisioningOptions>>().Value,
            app.Services,
            NullLogger<AzurePublishingContext>.Instance,
            step);
 
        await context.WriteModelAsync(model, environment);
 
        await Verify(await File.ReadAllTextAsync(Path.Combine(workspace.Path, "main.bicep")), "bicep")
            .AppendContentAsFile(await File.ReadAllTextAsync(Path.Combine(workspace.Path, "entra-web", "entra-web.bicep")), "bicep");
    }
 
    [Fact]
    public async Task Publish_TenantResourceSuffix_ReusesExplicitNamedDeploymentParameter()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputHelper, workspace.Path);
        var suffix = builder.AddParameter("tenantResourceSuffix", "shared");
        builder.AddEntraIdApplication("entra-web");
        builder.AddEntraIdApplication("entra-api")
            .WithParameter(AzureBicepResource.KnownParameters.TenantResourceSuffix, suffix);
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        await using var step = await new TestPipelineActivityReporter(outputHelper).CreateStepAsync("Publishing shared tenant suffix");
        var context = new AzurePublishingContext(
            workspace.Path,
            app.Services.GetRequiredService<IOptions<AzureProvisioningOptions>>().Value,
            app.Services,
            NullLogger<AzurePublishingContext>.Instance,
            step);
 
        await context.WriteModelAsync(model, Assert.Single(model.Resources.OfType<AzureEnvironmentResource>()));
 
        Assert.Same(context.ParameterLookup[suffix.Resource],
            Assert.Single(context.MainInfrastructure.GetProvisionableResources().OfType<ProvisioningParameter>(),
                parameter => parameter.BicepIdentifier == AzureBicepResource.KnownParameters.TenantResourceSuffix));
        await Verify(await File.ReadAllTextAsync(Path.Combine(workspace.Path, "main.bicep")), "bicep");
    }
 
    [Fact]
    public async Task Publish_EndpointRedirectUri_UsesContainerAppEnvironmentHostAndOutputs()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputHelper, workspace.Path);
        builder.AddAzureContainerAppEnvironment("env");
        var entra = builder.AddEntraIdApplication("entra-web");
        var web = builder.AddContainer("web", "image")
            .WithHttpEndpoint(targetPort: 8080)
            .WithExternalHttpEndpoints()
            .WithReference(entra);
        entra.WithRedirectUris(web.GetEndpoint("http"), "/signin-oidc");
 
        using var app = builder.Build();
        await ExecuteBeforeStartHooksAsync(app, default);
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var environment = model.Resources.OfType<AzureEnvironmentResource>().Single();
        await using var step = await new TestPipelineActivityReporter(outputHelper).CreateStepAsync("Publishing endpoint redirect URI");
        var context = new AzurePublishingContext(
            workspace.Path,
            app.Services.GetRequiredService<IOptions<AzureProvisioningOptions>>().Value,
            app.Services,
            NullLogger<AzurePublishingContext>.Instance,
            step);
 
        await context.WriteModelAsync(model, environment);
 
        await Verify(await File.ReadAllTextAsync(Path.Combine(workspace.Path, "main.bicep")), "bicep")
            .AppendContentAsFile(await File.ReadAllTextAsync(Path.Combine(workspace.Path, "entra-web", "entra-web.bicep")), "bicep");
    }
 
    [Fact]
    public async Task Publish_FederatedManagedIdentityCredential_PublishesConsumerIdentityClientId()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputHelper, workspace.Path);
        builder.AddAzureContainerAppEnvironment("env");
        var entra = builder.AddEntraIdApplication("entra-web")
            .PublishWithFederatedManagedIdentityCredential();
        builder.AddContainer("web", "image")
            .WithHttpEndpoint(targetPort: 8080)
            .WithExternalHttpEndpoints()
            .WithReference(entra);
 
        using var app = builder.Build();
        await ExecuteBeforeStartHooksAsync(app, default);
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var environment = model.Resources.OfType<AzureEnvironmentResource>().Single();
        await using var step = await new TestPipelineActivityReporter(outputHelper).CreateStepAsync("Publishing federated credential");
        var context = new AzurePublishingContext(
            workspace.Path,
            app.Services.GetRequiredService<IOptions<AzureProvisioningOptions>>().Value,
            app.Services,
            NullLogger<AzurePublishingContext>.Instance,
            step);
 
        await context.WriteModelAsync(model, environment);
 
        await Verify(await File.ReadAllTextAsync(Path.Combine(workspace.Path, "web", "web.bicep")), "bicep");
    }
 
    [Fact]
    public async Task Publish_CreatesSelfContainedGraphModulesAndOutputReferences()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputHelper, workspace.Path);
        var api = builder.AddEntraIdApplication("entra-api");
        var web = builder.AddEntraIdApplication("entra-web")
            .WithSignInAudience(EntraIdSignInAudience.AzureADMultipleOrgs)
            .WithRedirectUris("https://localhost:7123/signin-oidc");
        builder.AddExistingEntraApplication("existing", tenantId: TenantId, clientId: ClientId);
        builder.AddResource(new ProjectResource("web")).WithReference(web);
        builder.AddResource(new ProjectResource("api")).WithReference(api);
        builder.AddBicepTemplateString("consumer",
            """
            param location string = resourceGroup().location
            param tenantId string
            param clientId string
 
            output authority string = 'https://login.microsoftonline.com/${tenantId}/v2.0'
            output applicationClientId string = clientId
            """)
            .WithParameter("tenantId", web.Resource.TenantIdOutputReference)
            .WithParameter("clientId", web.Resource.ClientIdOutputReference);
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var environment = model.Resources.OfType<AzureEnvironmentResource>().Single();
        var options = app.Services.GetRequiredService<IOptions<AzureProvisioningOptions>>().Value;
        await using var step = await new TestPipelineActivityReporter(outputHelper).CreateStepAsync("Publishing Entra registrations");
        var context = new AzurePublishingContext(
            workspace.Path, options, app.Services, NullLogger<AzurePublishingContext>.Instance, step);
 
        await context.WriteModelAsync(model, environment);
 
        var mainBicep = await File.ReadAllTextAsync(Path.Combine(workspace.Path, "main.bicep"));
        var apiBicep = await File.ReadAllTextAsync(Path.Combine(workspace.Path, "entra-api", "entra-api.bicep"));
        var webBicep = await File.ReadAllTextAsync(Path.Combine(workspace.Path, "entra-web", "entra-web.bicep"));
        var consumerBicep = await File.ReadAllTextAsync(Path.Combine(workspace.Path, "consumer", "consumer.bicep"));
        Assert.Equal(new[] { "consumer", "entra-api", "entra-web" }, Directory.GetDirectories(workspace.Path).Select(Path.GetFileName).Order());
        await Verify(mainBicep, "bicep")
            .AppendContentAsFile(apiBicep, "bicep")
            .AppendContentAsFile(webBicep, "bicep")
            .AppendContentAsFile(consumerBicep, "bicep");
    }
 
    [Theory]
    [InlineData(EntraIdSignInAudience.AzureADMyOrg)]
    [InlineData(EntraIdSignInAudience.AzureADMultipleOrgs)]
    [InlineData(EntraIdSignInAudience.AzureADandPersonalMicrosoftAccount)]
    [InlineData(EntraIdSignInAudience.PersonalMicrosoftAccount)]
    public async Task WithSignInAudience_WritesResourceValueToInfrastructure(EntraIdSignInAudience audience)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-web");
        entra.WithSignInAudience(audience);
 
        Assert.Equal(audience, entra.Resource.SignInAudience);
        Assert.Equal(new[] { "tenantResourceSuffix" }, entra.Resource.Parameters.Keys);
        await Verify(entra.Resource.GetBicepTemplateString(), "bicep");
    }
 
    [Theory]
    [InlineData(DistributedApplicationOperation.Run)]
    [InlineData(DistributedApplicationOperation.Publish)]
    public async Task WithIdTokenIssuance_WritesResourceValueToInfrastructure(DistributedApplicationOperation operation)
    {
        using var builder = TestDistributedApplicationBuilder.Create(operation);
        var entra = builder.AddEntraIdApplication("entra-web");
 
        Assert.Same(entra, entra.WithIdTokenIssuance());
        Assert.Same(entra, entra.WithIdTokenIssuance());
        Assert.True(entra.Resource.EnableIdTokenIssuance);
        Assert.Equal(new[] { "tenantResourceSuffix" }, entra.Resource.Parameters.Keys);
        await Verify(entra.Resource.GetBicepTemplateString(), "bicep");
    }
 
    [Fact]
    public void WithRedirectUris_ValidatesAndAccumulatesResourceValues()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-web");
 
        Assert.Same(entra, entra.WithRedirectUris("https://localhost:7123/signin-oidc"));
        entra.WithRedirectUris("https://localhost:7123/signout-callback-oidc");
        Assert.Throws<ArgumentNullException>(() => EntraIdResourceExtensions.WithRedirectUris(null!, "https://localhost:7123/signin-oidc"));
        Assert.Throws<ArgumentNullException>(() => entra.WithRedirectUris((string[])null!));
        Assert.Throws<ArgumentException>(() => entra.WithRedirectUris("https://localhost:7252/callback", ""));
        Assert.Equal(new[] { "https://localhost:7123/signin-oidc", "https://localhost:7123/signout-callback-oidc" }, entra.Resource.RedirectUris);
    }
 
    [Fact]
    public void WithRedirectUris_Parameters_ValidatesAndAccumulates()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-web");
        var signIn = builder.AddParameter("signin");
        var signOut = builder.AddParameter("signout");
 
        Assert.Same(entra, entra.WithRedirectUris(signIn, signOut));
        Assert.Throws<ArgumentNullException>(() => EntraIdResourceExtensions.WithRedirectUris(null!, signIn));
        Assert.Throws<ArgumentNullException>(() => entra.WithRedirectUris((IResourceBuilder<ParameterResource>[])null!));
        Assert.Throws<ArgumentNullException>(() => entra.WithRedirectUris(signIn, (IResourceBuilder<ParameterResource>)null!));
        Assert.Equal(new[] { signIn.Resource, signOut.Resource }, entra.Resource.RedirectUriParameters);
    }
 
    [Fact]
    public async Task WithRedirectUris_Parameters_WritesSplitParameterValues()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithRedirectUris(builder.AddParameter("web-redirect-uris"))
            .WithRedirectUris("https://localhost:7123/signin-oidc")
            .WithRedirectUris(builder.AddParameter("extra-redirect-uris"));
 
        await Verify(entra.Resource.GetBicepTemplateString(), "bicep");
        Assert.Equal(new[] { "tenantResourceSuffix", "web_redirect_uris", "extra_redirect_uris" }, entra.Resource.Parameters.Keys);
    }
 
    [Fact]
    public async Task WithRedirectUris_SingleParameter_WritesSplitParameterValue()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithRedirectUris(builder.AddParameter("web-redirect-uris"));
 
        await Verify(entra.Resource.GetBicepTemplateString(), "bicep");
    }
 
    [Fact]
    public async Task WithRedirectUris_Endpoint_PreservesDeferredValues()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var environment = builder.AddResource(new TestComputeEnvironment("env"));
        var web = builder.AddContainer("web", "image").WithHttpsEndpoint(targetPort: 8080).WithComputeEnvironment(environment);
        var endpoint = web.GetEndpoint("https");
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithRedirectUris(endpoint, "/signin-oidc", "/signout-callback-oidc");
 
        await Verify(entra.Resource.GetBicepTemplateString(), "bicep");
        Assert.All(new[] { "redirectUri0", "redirectUri1" }, name =>
        {
            var expression = Assert.IsType<ReferenceExpression>(entra.Resource.Parameters[name]);
            Assert.Equal("https://{0}", expression.Format);
        });
        Assert.Equal("https://web.contoso.example",
            await Assert.IsType<ReferenceExpression>(entra.Resource.Parameters["redirectUri0"]).GetValueAsync(default));
        Assert.Equal("https://web.contoso.example",
            await Assert.IsType<ReferenceExpression>(entra.Resource.Parameters["redirectUri1"]).GetValueAsync(default));
    }
 
    [Fact]
    public async Task WithRedirectUris_Endpoint_ResolvesSharedBrowserOrigin()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var web = builder.AddContainer("web", "image").WithHttpsEndpoint(targetPort: 8080)
            .WithEndpoint("https", endpoint => endpoint.AllocatedEndpoint = new(endpoint, "localhost", 7081));
        var endpoint = web.GetEndpoint("https");
        var entra = builder.AddEntraIdApplication("entra-web").WithRedirectUris(endpoint, "/signin-oidc", "/signout-callback-oidc");
        web.WithUrlForEndpoint("https", url => url.Url = "https://web.dev.localhost:7081/swagger?x=1#top");
        _ = entra.Resource.GetBicepTemplateString();
        using var app = builder.Build();
        _ = app.Services.GetRequiredService<ApplicationOrchestratorProxy>();
 
        await builder.Eventing.PublishResourceEndpointsAllocatedAsync(new ResourceEndpointsAllocatedEvent(web.Resource, app.Services), CancellationToken.None);
 
        var signIn = Assert.IsType<ReferenceExpression>(entra.Resource.Parameters["redirectUri0"]);
        var signOut = Assert.IsType<ReferenceExpression>(entra.Resource.Parameters["redirectUri1"]);
        Assert.Equal("https://web.dev.localhost:7081", await signIn.GetValueAsync(CancellationToken.None));
        Assert.Equal("https://web.dev.localhost:7081", await signOut.GetValueAsync(CancellationToken.None));
        var provider = Assert.IsType<EntraIdRunModeBrowserOrigin>(Assert.Single(signIn.ValueProviders));
        Assert.Equal(endpoint.PrimaryBrowserOrigin.ValueExpression, provider.ValueExpression);
        Assert.Same(endpoint, Assert.Single(provider.References));
    }
 
    [Fact]
    public void WithRedirectUris_Endpoint_ValidatesBeforeAddingValues()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-web");
        var endpoint = builder.AddContainer("web", "image").GetEndpoint("https");
 
        Assert.Throws<ArgumentNullException>("builder", () => EntraIdResourceExtensions.WithRedirectUris(null!, endpoint, "/signin-oidc"));
        Assert.Throws<ArgumentNullException>("endpoint", () => entra.WithRedirectUris((EndpointReference)null!, "/signin-oidc"));
        Assert.Throws<ArgumentNullException>("callbackPaths", () => entra.WithRedirectUris(endpoint, (string[])null!));
        Assert.Throws<ArgumentException>("callbackPaths", () => entra.WithRedirectUris(endpoint));
        Assert.Throws<ArgumentNullException>("callbackPaths", () => entra.WithRedirectUris(endpoint, "/valid", null!));
        Assert.Throws<ArgumentException>("callbackPaths", () => entra.WithRedirectUris(endpoint, "/valid", ""));
        Assert.Throws<ArgumentException>("callbackPaths", () => entra.WithRedirectUris(endpoint, "/valid", " "));
        Assert.Throws<ArgumentException>("callbackPaths", () => entra.WithRedirectUris(endpoint, "/valid", "signin-oidc"));
        _ = entra.Resource.GetBicepTemplateString();
        Assert.Equal(["tenantResourceSuffix"], entra.Resource.Parameters.Keys);
    }
 
    [Fact]
    public async Task WithRedirectUris_Endpoint_RunMode_UsesAllocatedEndpointUrl()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var web = builder.AddContainer("web", "image").WithHttpsEndpoint(targetPort: 8080, name: "https");
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithRedirectUris(web.GetEndpoint("https"), "/signin-oidc", "/signout-callback-oidc");
        Assert.Empty(web.Resource.Annotations.OfType<ResourceUrlsCallbackAnnotation>());
 
        await Verify(entra.Resource.GetBicepTemplateString(), "bicep");
        Assert.Equal(new[] { "tenantResourceSuffix", "redirectUri0", "redirectUri1" }, entra.Resource.Parameters.Keys);
        Assert.All(
            entra.Resource.Parameters.Where(p => p.Key.StartsWith("redirectUri", StringComparison.Ordinal)),
            p => Assert.IsType<ReferenceExpression>(p.Value));
    }
 
    [Fact]
    public async Task WithRedirectUris_Endpoint_PublishMode_UsesComputeEnvironmentHost()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var environment = builder.AddResource(new TestComputeEnvironment("env"));
        var web = builder.AddContainer("web", "image")
            .WithHttpsEndpoint(targetPort: 8080, name: "https");
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithRedirectUris(web.GetEndpoint("https"), "/signin-oidc");
        web.WithComputeEnvironment(environment);
 
        await Verify(entra.Resource.GetBicepTemplateString(), "bicep");
    }
 
    [Theory]
    [InlineData(false, false, false)]
    [InlineData(false, false, true)]
    [InlineData(false, true, false)]
    [InlineData(false, true, true)]
    [InlineData(true, false, false)]
    [InlineData(true, false, true)]
    [InlineData(true, true, false)]
    [InlineData(true, true, true)]
    public void WithRedirectUris_Endpoint_PublishMode_RequiresDeployedTls(bool appService, bool upgrade, bool https)
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        IResourceBuilder<IComputeEnvironmentResource> environment = appService
            ? builder.AddAzureAppServiceEnvironment("env").WithHttpsUpgrade(upgrade)
            : builder.AddAzureContainerAppEnvironment("env").WithHttpsUpgrade(upgrade);
        var web = builder.AddContainer("web", "image")
            .WithEndpoint(name: "web", scheme: https ? "https" : "http", targetPort: 8080, isExternal: true);
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithRedirectUris(web.GetEndpoint("web"), "/signin-oidc");
        web.WithComputeEnvironment(environment);
 
        if (!upgrade && !https)
        {
            var error = Assert.Throws<InvalidOperationException>(entra.Resource.GetBicepTemplateString);
            Assert.Equal(
                "Endpoint 'web' on resource 'web' cannot be used for an Entra redirect URI because TLS is disabled in compute environment 'env'. " +
                "Use an HTTPS endpoint, enable HTTPS upgrade, or supply an explicit HTTPS redirect URI.", error.Message);
        }
        else
        {
            _ = entra.Resource.GetBicepTemplateString();
            var origin = Assert.IsType<ReferenceExpression>(entra.Resource.Parameters["redirectUri0"]);
            Assert.Equal("https://{0}", origin.Format);
        }
    }
 
    [Fact]
    public async Task WithRedirectUris_Endpoint_RunMode_UsesPrimaryUrlWhenItIsADevLocalhostName()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var web = builder.AddContainer("web", "image")
            .WithHttpsEndpoint(targetPort: 8080, name: "https")
            .WithEndpoint("https", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 7081));
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithRedirectUris(web.GetEndpoint("https"), "/signin-oidc");
        web.WithUrlForEndpoint("https", url => url.Url = "https://web-hashr.dev.localhost:7081");
 
        _ = entra.Resource.GetBicepTemplateString();
        var expression = Assert.IsType<ReferenceExpression>(entra.Resource.Parameters["redirectUri0"]);
        var resolveTask = expression.GetValueAsync(new ValueProviderContext(), CancellationToken.None).AsTask();
        Assert.False(resolveTask.IsCompleted);
 
        using var app = builder.Build();
        _ = app.Services.GetRequiredService<ApplicationOrchestratorProxy>();
        await builder.Eventing.PublishResourceEndpointsAllocatedAsync(new ResourceEndpointsAllocatedEvent(web.Resource, app.Services), CancellationToken.None);
 
        Assert.Equal("https://web-hashr.dev.localhost:7081", await resolveTask.WaitAsync(TimeSpan.FromSeconds(10)));
    }
 
    [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")]
    private static extern Task ExecuteBeforeStartHooksAsync(DistributedApplication app, CancellationToken cancellationToken);
 
    [Fact]
    public async Task WithRedirectUris_Endpoint_RunMode_UsesHighestRankedFinalOrigin()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var web = builder.AddContainer("web", "image").WithHttpsEndpoint(targetPort: 8080)
            .WithEndpoint("https", endpoint => endpoint.AllocatedEndpoint = new(endpoint, "localhost", 7081));
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithRedirectUris(web.GetEndpoint("https"), "/signin-oidc");
        web.WithUrlForEndpoint("https", url => url.Url = "https://web.dev.localhost:7081/swagger")
            .WithUrlForEndpoint("https", _ => new ResourceUrlAnnotation
            {
                Url = "https://public.example:9443/home",
                DisplayOrder = 100
            });
        _ = entra.Resource.GetBicepTemplateString();
        var expression = Assert.IsType<ReferenceExpression>(entra.Resource.Parameters["redirectUri0"]);
        using var app = builder.Build();
        _ = app.Services.GetRequiredService<ApplicationOrchestratorProxy>();
 
        await builder.Eventing.PublishResourceEndpointsAllocatedAsync(new ResourceEndpointsAllocatedEvent(web.Resource, app.Services), CancellationToken.None);
 
        Assert.Equal("https://public.example:9443", await expression.GetValueAsync(CancellationToken.None));
    }
 
    [Fact]
    public async Task WithRedirectUris_Endpoint_RunMode_IndirectProvisioningCycleFallsBackWithWarning()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var web = builder.AddContainer("web", "image").WithHttpsEndpoint(targetPort: 8080)
            .WithEndpoint("https", endpoint => endpoint.AllocatedEndpoint = new(endpoint, "localhost", 7081));
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithRedirectUris(web.GetEndpoint("https"), "/signin-oidc");
        var callbackEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        web.WithUrls(async context =>
        {
            callbackEntered.SetResult();
            await entra.Resource.ClientIdOutputReference.GetValueAsync(context.CancellationToken);
            context.Urls[0].Url = "https://web.example/home";
        });
        _ = entra.Resource.GetBicepTemplateString();
        var expression = Assert.IsType<ReferenceExpression>(entra.Resource.Parameters["redirectUri0"]);
        using var app = builder.Build();
        _ = app.Services.GetRequiredService<ApplicationOrchestratorProxy>();
        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(45));
        var publish = builder.Eventing.PublishResourceEndpointsAllocatedAsync(new(web.Resource, app.Services), cts.Token);
        try
        {
            await callbackEntered.Task.WaitAsync(cts.Token);
            Assert.Equal("https://localhost:7081", await expression.GetValueAsync(cts.Token));
            Assert.False(publish.IsCompleted);
            var pendingOrigin = web.GetEndpoint("https").BrowserOrigins.GetValueAsync(cts.Token).AsTask();
            Assert.False(pendingOrigin.IsCompleted);
            await using var logs = app.Services.GetRequiredService<ResourceLoggerService>()
                .WatchAsync(web.Resource).GetAsyncEnumerator(cts.Token);
            Assert.True(await logs.MoveNextAsync());
            Assert.Contains(logs.Current, line => line.Content.Contains(
                "Using allocated origin 'https://localhost:7081' for the Entra redirect URI.", StringComparison.Ordinal));
            entra.Resource.Outputs["clientId"] = ClientId;
            entra.Resource.ProvisioningTaskCompletionSource!.TrySetResult();
            await publish.WaitAsync(cts.Token);
            Assert.Equal("https://web.example,https://localhost:7081", await pendingOrigin);
            Assert.Equal("https://web.example", await expression.GetValueAsync(cts.Token));
        }
        finally
        {
            cts.Cancel();
            await publish;
        }
    }
 
    [Fact]
    public async Task WithRedirectUris_Endpoint_RunMode_CancellationDoesNotFaultBrowserSnapshot()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var web = builder.AddContainer("web", "image").WithHttpsEndpoint(targetPort: 8080)
            .WithEndpoint("https", endpoint => endpoint.AllocatedEndpoint = new(endpoint, "localhost", 7081));
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithRedirectUris(web.GetEndpoint("https"), "/signin-oidc");
        _ = entra.Resource.GetBicepTemplateString();
        var expression = Assert.IsType<ReferenceExpression>(entra.Resource.Parameters["redirectUri0"]);
        using var cts = new CancellationTokenSource();
        var pending = expression.GetValueAsync(cts.Token).AsTask();
 
        cts.Cancel();
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => pending);
        using var app = builder.Build();
        _ = app.Services.GetRequiredService<ApplicationOrchestratorProxy>();
        await builder.Eventing.PublishResourceEndpointsAllocatedAsync(new(web.Resource, app.Services), CancellationToken.None);
        Assert.Equal("https://localhost:7081", await expression.GetValueAsync(CancellationToken.None));
    }
 
    [Fact]
    public async Task WithRedirectUris_Endpoint_RunMode_PropagatesUrlCustomizationFailure()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var web = builder.AddContainer("web", "image").WithHttpsEndpoint(targetPort: 8080)
            .WithEndpoint("https", endpoint => endpoint.AllocatedEndpoint = new(endpoint, "localhost", 7081));
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithRedirectUris(web.GetEndpoint("https"), "/signin-oidc");
        web.WithUrls(_ => throw new InvalidOperationException("URL customization failed."));
        _ = entra.Resource.GetBicepTemplateString();
        var expression = Assert.IsType<ReferenceExpression>(entra.Resource.Parameters["redirectUri0"]);
        using var app = builder.Build();
        _ = app.Services.GetRequiredService<ApplicationOrchestratorProxy>();
 
        await builder.Eventing.PublishResourceEndpointsAllocatedAsync(new ResourceEndpointsAllocatedEvent(web.Resource, app.Services), CancellationToken.None);
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
            await expression.GetValueAsync(CancellationToken.None).AsTask().WaitAsync(TimeSpan.FromSeconds(5)));
        Assert.Equal("URL customization failed.", exception.Message);
    }
 
    [Fact]
    public async Task WithRedirectUris_Host_WritesHostParameterWithPaths()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var host = builder.AddParameter("web-host");
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithRedirectUris(host, "/signin-oidc", "/signout-callback-oidc");
 
        await Verify(entra.Resource.GetBicepTemplateString(), "bicep");
        var expression = Assert.IsType<ReferenceExpression>(entra.Resource.Parameters["redirectUri0"]);
        Assert.Equal("https://{0}/signin-oidc", expression.Format);
        Assert.Same(host.Resource, Assert.Single(expression.ValueProviders));
    }
 
    [Fact]
    public void WithRedirectUris_Host_RequiresPathStartingWithSlash()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var host = builder.AddParameter("web-host");
        var entra = builder.AddEntraIdApplication("entra-web");
 
        Assert.Throws<ArgumentException>("callbackPath", () => entra.WithRedirectUris(host, "signin-oidc"));
        Assert.Throws<ArgumentException>("callbackPath", () => entra.WithRedirectUris(host, "/signin-oidc", "other"));
    }
 
    [Fact]
    public void WithRedirectUris_SingleParameter_BindsToParameterOverload()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithRedirectUris(builder.AddParameter("web-redirect-uris"));
 
        Assert.Single(entra.Resource.RedirectUriParameters);
    }
 
    [Fact]
    public async Task WithRedirectUris_Endpoint_RunMode_RelativeDisplayUrlResolvesAgainstEndpoint()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var web = builder.AddContainer("web", "image")
            .WithHttpsEndpoint(targetPort: 8080, name: "https")
            .WithEndpoint("https", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 7081));
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithRedirectUris(web.GetEndpoint("https"), "/signin-oidc");
        web.WithUrlForEndpoint("https", url => url.Url = "/swagger");
 
        _ = entra.Resource.GetBicepTemplateString();
        var expression = Assert.IsType<ReferenceExpression>(entra.Resource.Parameters["redirectUri0"]);
 
        using var app = builder.Build();
        _ = app.Services.GetRequiredService<ApplicationOrchestratorProxy>();
        await builder.Eventing.PublishResourceEndpointsAllocatedAsync(new ResourceEndpointsAllocatedEvent(web.Resource, app.Services), CancellationToken.None);
 
        Assert.Equal("https://localhost:7081", await expression.GetValueAsync(new ValueProviderContext(), CancellationToken.None).AsTask().WaitAsync(TimeSpan.FromSeconds(10)));
    }
 
    [Fact]
    public async Task WithRedirectUris_Endpoint_RunMode_OnlyUsesUrlsOfTheSameResource()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var web = builder.AddContainer("web", "image")
            .WithHttpsEndpoint(targetPort: 8080, name: "https")
            .WithEndpoint("https", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 7081));
        var other = builder.AddContainer("other", "image")
            .WithHttpsEndpoint(targetPort: 8080, name: "https")
            .WithEndpoint("https", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 7082));
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithRedirectUris(web.GetEndpoint("https"), "/signin-oidc");
 
        _ = entra.Resource.GetBicepTemplateString();
        var expression = Assert.IsType<ReferenceExpression>(entra.Resource.Parameters["redirectUri0"]);
 
        web.WithUrls(context =>
        {
            context.Urls[0].DisplayLocation = UrlDisplayLocation.DetailsOnly;
            context.Urls.Add(new()
            {
                Url = "https://other.dev.localhost:7082",
                Endpoint = other.GetEndpoint("https"),
                DisplayLocation = UrlDisplayLocation.SummaryAndDetails
            });
        });
        using var app = builder.Build();
        _ = app.Services.GetRequiredService<ApplicationOrchestratorProxy>();
        await builder.Eventing.PublishResourceEndpointsAllocatedAsync(new ResourceEndpointsAllocatedEvent(web.Resource, app.Services), CancellationToken.None);
 
        Assert.Equal("https://localhost:7081", await expression.GetValueAsync(new ValueProviderContext(), CancellationToken.None).AsTask().WaitAsync(TimeSpan.FromSeconds(10)));
    }
 
    [Fact]
    public async Task WithRedirectUris_Endpoint_RunMode_ClearedDisplayUrlsUseAllocatedOrigin()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var web = builder.AddContainer("web", "image")
            .WithHttpsEndpoint(targetPort: 8080, name: "https")
            .WithEndpoint("https", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 7081));
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithRedirectUris(web.GetEndpoint("https"), "/signin-oidc");
        web.WithUrls(context => context.Urls.Clear());
 
        await Verify(entra.Resource.GetBicepTemplateString(), "bicep");
        var expression = Assert.IsType<ReferenceExpression>(entra.Resource.Parameters["redirectUri0"]);
 
        using var app = builder.Build();
        _ = app.Services.GetRequiredService<ApplicationOrchestratorProxy>();
        await builder.Eventing.PublishResourceEndpointsAllocatedAsync(new ResourceEndpointsAllocatedEvent(web.Resource, app.Services), CancellationToken.None);
 
        Assert.Equal("https://localhost:7081", await expression.GetValueAsync(CancellationToken.None));
        Assert.Empty(web.Resource.Annotations.OfType<ResourceUrlAnnotation>());
    }
 
    [Fact]
    public void WithRedirectUris_DerivedParameterNames_DoNotCollideWithUserParameters()
    {
        foreach (var derivedFirst in new[] { false, true })
        {
            using var builder = TestDistributedApplicationBuilder.Create();
            var existing = builder.AddParameter("redirectUri0", "https://existing.contoso.com/callback");
            var host = builder.AddParameter("host", "web.contoso.com");
            var entra = builder.AddEntraIdApplication("entra");
            if (derivedFirst)
            {
                entra.WithRedirectUris(host, "/signin-oidc").WithRedirectUris(existing);
            }
            else
            {
                entra.WithRedirectUris(existing).WithRedirectUris(host, "/signin-oidc");
            }
 
            var bicep = entra.Resource.GetBicepTemplateString();
            Assert.Same(existing.Resource, entra.Resource.Parameters["redirectUri0"]);
            Assert.IsType<ReferenceExpression>(entra.Resource.Parameters["redirectUri1"]);
            Assert.Contains("split(redirectUri0, ',')", bicep);
            Assert.Contains("redirectUri1", bicep);
 
            // Generating again must give the same names.
            Assert.Equal(bicep, entra.Resource.GetBicepTemplateString());
        }
    }
 
    [Fact]
    public void WithRedirectUris_Endpoint_RequiresPathStartingWithSlash()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var web = builder.AddContainer("web", "image").WithHttpsEndpoint(targetPort: 8080, name: "https");
        var entra = builder.AddEntraIdApplication("entra-web");
 
        Assert.Throws<ArgumentException>("callbackPaths", () => entra.WithRedirectUris(web.GetEndpoint("https")));
        Assert.Throws<ArgumentException>("callbackPaths", () => entra.WithRedirectUris(web.GetEndpoint("https"), "signin-oidc"));
    }
 
#pragma warning disable ASPIRECOMPUTE002 // Test compute environment implements the experimental host address member.
    private sealed class TestComputeEnvironment(string name) : Resource(name), IComputeEnvironmentResource
    {
        public ReferenceExpression GetHostAddressExpression(EndpointReference endpointReference) =>
            ReferenceExpression.Create($"{endpointReference.Resource.Name}.contoso.example");
 
        public ReferenceExpression GetEndpointPropertyExpression(EndpointReferenceExpression endpointReferenceExpression)
        {
            if (endpointReferenceExpression.Property is EndpointProperty.TlsEnabled)
            {
                return ReferenceExpression.Create($"{bool.TrueString}");
            }
 
            Assert.Equal(EndpointProperty.PrimaryBrowserOrigin, endpointReferenceExpression.Property);
            return ReferenceExpression.Create($"https://{GetHostAddressExpression(endpointReferenceExpression.Endpoint)}");
        }
    }
#pragma warning restore ASPIRECOMPUTE002
 
    [Fact]
    public async Task ConfigureInfrastructure_WritesDirectResourcePropertyValues()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-web");
        entra.Resource.SignInAudience = EntraIdSignInAudience.AzureADMultipleOrgs;
        entra.Resource.AddRedirectUri("https://localhost:7123/signin-oidc");
        entra.Resource.EnableIdTokenIssuance = true;
 
        await Verify(entra.Resource.GetBicepTemplateString(), "bicep");
    }
 
    [Fact]
    public void WithIdTokenIssuance_ThrowsWhenBuilderIsNull()
    {
        var exception = Assert.Throws<ArgumentNullException>(() => EntraIdResourceExtensions.WithIdTokenIssuance(null!));
 
        Assert.Equal("builder", exception.ParamName);
    }
 
    [Theory]
    [InlineData(DistributedApplicationOperation.Run)]
    [InlineData(DistributedApplicationOperation.Publish)]
    public async Task WithIdTokenIssuance_ThrowsWithoutProvisioningExistingApplication(DistributedApplicationOperation operation)
    {
        using var builder = TestDistributedApplicationBuilder.Create(operation);
        var entra = builder.AddExistingEntraApplication("entra-web", tenantId: TenantId, clientId: ClientId);
 
        var exception = Assert.Throws<InvalidOperationException>(entra.WithIdTokenIssuance);
 
        Assert.Equal(
            "Entra ID application 'entra-web' is an existing registration, and Aspire does not modify existing registrations. " +
            "Enable 'ID tokens (used for implicit and hybrid flows)' in the registration's Authentication settings in the Microsoft Entra admin center.",
            exception.Message);
        Assert.False(entra.Resource.EnableIdTokenIssuance);
 
        Assert.True(entra.Resource.IsExistingApplication);
        Assert.Equal(TenantId, entra.Resource.TenantId);
        Assert.Equal(ClientId, entra.Resource.ClientId);
        Assert.Null(entra.Resource.ProvisioningTaskCompletionSource);
        Assert.Empty(entra.Resource.Annotations.OfType<PipelineStepAnnotation>());
        Assert.Empty(entra.Resource.Annotations.OfType<PipelineConfigurationAnnotation>());
        Assert.Null(await ManifestUtils.GetManifestOrNull(entra.Resource));
    }
 
    [Fact]
    public async Task AddExistingEntraApplication_DoesNotPublishOrAwaitProvisioning()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddExistingEntraApplication("entra-web", tenantId: TenantId, clientId: ClientId);
 
        Assert.True(entra.Resource.IsExistingApplication);
        Assert.Null(entra.Resource.ProvisioningTaskCompletionSource);
        Assert.Empty(entra.Resource.Annotations.OfType<PipelineStepAnnotation>());
        Assert.Empty(entra.Resource.Annotations.OfType<PipelineConfigurationAnnotation>());
        Assert.Null(await ManifestUtils.GetManifestOrNull(entra.Resource));
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task AsExistingApplication_PreservesAzureProvisioningSetup(bool useParameters)
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var customization = new PipelineConfigurationAnnotation(_ => { });
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithAnnotation(customization);
        var existing = useParameters
            ? entra.AsExistingApplication(builder.AddParameter("tenant-id", TenantId), builder.AddParameter("client-id", ClientId))
            : entra.AsExistingApplication(TenantId, ClientId);
 
        Assert.Same(entra, existing);
        Assert.True(existing.Resource.IsExistingApplication);
        Assert.Null(existing.Resource.ProvisioningTaskCompletionSource);
        Assert.Single(builder.Resources.OfType<AzureEnvironmentResource>());
        Assert.Empty(existing.Resource.Annotations.OfType<PipelineStepAnnotation>());
        Assert.Same(customization, Assert.Single(existing.Resource.Annotations.OfType<PipelineConfigurationAnnotation>()));
        Assert.Null(await ManifestUtils.GetManifestOrNull(existing.Resource));
    }
 
    [Theory]
    [InlineData(false, false)]
    [InlineData(true, false)]
    [InlineData(false, true)]
    [InlineData(true, true)]
    public async Task AddExistingEntraApplication_DeployDoesNotRequireAzure(bool useParameters, bool useExport)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(
            DistributedApplicationOperation.Publish, outputHelper, workspace.Path, step: WellKnownPipelineSteps.Deploy);
        var entra = useExport
            ? builder.AddExistingEntraApplicationForExport("entra-web",
                useParameters ? builder.AddParameter("tenant-id", TenantId) : TenantId,
                useParameters ? builder.AddParameter("client-id", ClientId) : ClientId)
            : useParameters
            ? builder.AddExistingEntraApplication("entra-web",
                builder.AddParameter("tenant-id", TenantId), builder.AddParameter("client-id", ClientId))
            : builder.AddExistingEntraApplication("entra-web", TenantId, ClientId);
 
        Assert.True(entra.Resource.IsExistingApplication);
        Assert.Empty(builder.Resources.OfType<AzureEnvironmentResource>());
        Assert.All(builder.Services, service => Assert.NotEqual(typeof(ITokenCredentialProvider), service.ServiceType));
        // Fail instead of acquiring real credentials if Azure authentication is accidentally activated.
        builder.Services.AddSingleton<ITokenCredentialProvider>(_ => throw new InvalidOperationException("Azure authentication must not be used."));
        var reporter = new TestPipelineActivityReporter(outputHelper);
        builder.Services.AddSingleton<IPipelineActivityReporter>(reporter);
        var deploymentExecuted = false;
        builder.Pipeline.AddStep("verify-existing-registration", context =>
        {
            Assert.Empty(context.Model.Resources.OfType<AzureEnvironmentResource>());
            Assert.All(context.PipelineContext.SelectedSteps, step =>
                Assert.False(step.Name is "validate-azure-login" or "create-provisioning-context" or "provision-azure-bicep-resources"));
            deploymentExecuted = true;
            return Task.CompletedTask;
        }, requiredBy: WellKnownPipelineSteps.Deploy);
 
        using var app = builder.Build();
        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
        await app.RunAsync(cts.Token);
 
        Assert.True(deploymentExecuted);
        Assert.True(reporter.CompletePublishCalled);
        Assert.NotEmpty(reporter.CompletedSteps);
        Assert.All(reporter.CompletedSteps, step => Assert.Equal(CompletionState.Completed, step.CompletionState));
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public void AddExistingEntraApplication_PreservesAzureProvisioningForOtherResources(bool addAzureFirst)
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var name = builder.AddParameter("arm-name", "existing-resource");
        var group = builder.AddParameter("arm-group", "existing-resource-group");
        if (addAzureFirst)
        {
            builder.AddAzureInfrastructure("other", _ => { }).AsExisting(name, group);
        }
 
        var existing = builder.AddExistingEntraApplication("entra-web", TenantId, ClientId);
        if (!addAzureFirst)
        {
            builder.AddAzureInfrastructure("other", _ => { }).AsExisting(name, group);
        }
 
        Assert.Single(builder.Resources.OfType<AzureEnvironmentResource>());
        Assert.Empty(existing.Resource.Annotations.OfType<PipelineStepAnnotation>());
        var azure = Assert.Single(builder.Resources.OfType<AzureProvisioningResource>(), resource => resource.Name == "other");
        Assert.Single(azure.Annotations.OfType<PipelineStepAnnotation>());
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public void AddExistingEntraApplication_PreservesProvisionedEntraRegistrations(bool addCreatedFirst)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        if (addCreatedFirst)
        {
            builder.AddEntraIdApplication("created");
        }
 
        builder.AddExistingEntraApplication("existing", TenantId, ClientId);
        if (!addCreatedFirst)
        {
            builder.AddEntraIdApplication("created");
        }
 
        Assert.Single(builder.Resources.OfType<AzureEnvironmentResource>());
        var created = Assert.Single(builder.Resources.OfType<EntraIdApplicationResource>(), resource => resource.Name == "created");
        // The provisioning step and the managed identity validation step.
        Assert.Equal(2, created.Annotations.OfType<PipelineStepAnnotation>().Count());
        Assert.NotNull(created.ProvisioningTaskCompletionSource);
    }
 
    [Fact]
    public void AddExistingEntraApplication_PreservesAppHostPipelineAnnotations()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var customization = new PipelineConfigurationAnnotation(_ =>
        {
        });
        var entra = builder.AddExistingEntraApplication("entra-web", tenantId: TenantId, clientId: ClientId)
            .WithAnnotation(customization);
 
        Assert.Same(customization, Assert.Single(entra.Resource.Annotations.OfType<PipelineConfigurationAnnotation>()));
    }
 
    [Theory]
    [InlineData(DistributedApplicationOperation.Run)]
    [InlineData(DistributedApplicationOperation.Publish)]
    public void AsExisting_RejectsArmResourceIdentity(DistributedApplicationOperation operation)
    {
        using var builder = TestDistributedApplicationBuilder.Create(operation);
        var entra = builder.AddEntraIdApplication("entra-web")
            .AsExisting(builder.AddParameter("application-name"), builder.AddParameter("resource-group"));
 
        var exception = Assert.Throws<InvalidOperationException>(entra.Resource.GetBicepTemplateString);
        Assert.Equal(
            "Entra ID applications are identified by tenant ID and client ID, not by an Azure resource name or resource group. " +
            "Use AddExistingEntraApplication(name, tenantId, clientId) to reference an existing registration without provisioning it.",
            exception.Message);
    }
 
    [Fact]
    public void GetBicepTemplate_RejectsInvalidSignInAudience()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-web");
        entra.Resource.SignInAudience = (EntraIdSignInAudience)42;
 
        var exception = Assert.Throws<InvalidOperationException>(entra.Resource.GetBicepTemplateString);
        Assert.Equal("'42' is not a valid EntraIdSignInAudience value.", exception.Message);
    }
 
    [Theory]
    [InlineData(null, null)]
    [InlineData("ABC123", "CN=Contoso")]
    public void GetBicepTemplate_RejectsInvalidCredentialBeforeProvisioning(string? thumbprint, string? distinguishedName)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-web")
            .WithCredential(new EntraIdStoreCertificateCredential
            {
                StorePath = "CurrentUser/My",
                Thumbprint = thumbprint,
                DistinguishedName = distinguishedName
            });
 
        var exception = Assert.Throws<InvalidOperationException>(entra.Resource.GetBicepTemplateString);
 
        Assert.Equal(thumbprint is null
            ? "Either Thumbprint or DistinguishedName must be set on EntraIdStoreCertificateCredential."
            : "Only one of Thumbprint or DistinguishedName can be set on EntraIdStoreCertificateCredential.",
            exception.Message);
    }
 
    [Theory]
    [InlineData(DistributedApplicationOperation.Run, EntraIdSignInAudience.AzureADMyOrg, null)]
    [InlineData(DistributedApplicationOperation.Publish, EntraIdSignInAudience.AzureADMyOrg, null)]
    [InlineData(DistributedApplicationOperation.Run, EntraIdSignInAudience.AzureADMultipleOrgs, "organizations")]
    [InlineData(DistributedApplicationOperation.Publish, EntraIdSignInAudience.AzureADMultipleOrgs, "organizations")]
    [InlineData(DistributedApplicationOperation.Run, EntraIdSignInAudience.AzureADandPersonalMicrosoftAccount, "common")]
    [InlineData(DistributedApplicationOperation.Publish, EntraIdSignInAudience.AzureADandPersonalMicrosoftAccount, "common")]
    [InlineData(DistributedApplicationOperation.Run, EntraIdSignInAudience.PersonalMicrosoftAccount, "consumers")]
    [InlineData(DistributedApplicationOperation.Publish, EntraIdSignInAudience.PersonalMicrosoftAccount, "consumers")]
    public async Task WithReference_CreatedApplication_UsesOutputs(
        DistributedApplicationOperation operation,
        EntraIdSignInAudience audience,
        string? signInTenant)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-web").WithSignInAudience(audience);
        entra.Resource.Outputs["tenantId"] = TenantId;
        entra.Resource.Outputs["clientId"] = ClientId;
        entra.Resource.ProvisioningTaskCompletionSource!.SetResult();
 
        var project = builder.AddResource(new ProjectResource("web")).WithReference(entra);
        var container = builder.AddContainer("worker", "myimage").WithReference(entra, connectionName: "ENTRA");
 
        var tenantId = operation == DistributedApplicationOperation.Run ? TenantId : "{entra-web.outputs.tenantId}";
        var clientId = operation == DistributedApplicationOperation.Run ? ClientId : "{entra-web.outputs.clientId}";
        var expectedProject = new Dictionary<string, string>
        {
            ["AzureAd__Instance"] = "https://login.microsoftonline.com/",
            ["AzureAd__TenantId"] = signInTenant ?? tenantId,
            ["AzureAd__ClientId"] = clientId
        };
        var expectedContainer = new Dictionary<string, string>
        {
            ["ENTRA_INSTANCE"] = "https://login.microsoftonline.com/",
            ["ENTRA_TENANT_ID"] = signInTenant ?? tenantId,
            ["ENTRA_CLIENT_ID"] = clientId
        };
        if (signInTenant is not null)
        {
            expectedProject["AzureAd__AppHomeTenantId"] = tenantId;
            expectedContainer["ENTRA_APP_HOME_TENANT_ID"] = tenantId;
        }
 
        Assert.Equal(expectedProject, await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            project.Resource, operation, TestServiceProvider.Instance));
        Assert.Equal(expectedContainer, await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            container.Resource, operation, TestServiceProvider.Instance));
    }
 
    [Fact]
    public async Task WithReference_EnvironmentResolutionWaitsForProvisionedOutputs()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-web");
        var node = builder.AddExecutable("node", "node", ".")
            .WithReference(entra);
 
        using var app = builder.Build();
        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        var environmentTask = EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            node.Resource, DistributedApplicationOperation.Run, app.Services).AsTask();
 
        Assert.False(environmentTask.IsCompleted);
        entra.Resource.Outputs["tenantId"] = TenantId;
        entra.Resource.Outputs["clientId"] = ClientId;
        entra.Resource.ProvisioningTaskCompletionSource!.SetResult();
 
        Assert.Equal(new Dictionary<string, string>
        {
            ["ENTRA_WEB_INSTANCE"] = "https://login.microsoftonline.com/",
            ["ENTRA_WEB_TENANT_ID"] = TenantId,
            ["ENTRA_WEB_CLIENT_ID"] = ClientId
        }, await environmentTask.WaitAsync(cts.Token));
    }
 
    [Theory]
    [InlineData(nameof(ProjectResource), false)]
    [InlineData(nameof(ExecutableResource), false)]
    [InlineData(nameof(ContainerResource), false)]
    [InlineData(nameof(DotnetProjectResource), false)]
    [InlineData(nameof(ProjectResource), true)]
    [InlineData(nameof(ExecutableResource), true)]
    [InlineData(nameof(ContainerResource), true)]
    [InlineData(nameof(DotnetProjectResource), true)]
    public void WithReference_DoesNotAddImplicitWaits(string resourceType, bool existing)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = existing
            ? builder.AddExistingEntraApplication("entra-web", tenantId: TenantId, clientId: ClientId)
            : builder.AddEntraIdApplication("entra-web");
 
        IResourceBuilder<IResourceWithEnvironment> consumer = resourceType switch
        {
            nameof(ProjectResource) => builder.AddResource(new ProjectResource("consumer")),
            nameof(ExecutableResource) => builder.AddExecutable("consumer", "node", "."),
            nameof(ContainerResource) => builder.AddContainer("consumer", "test-image"),
            nameof(DotnetProjectResource) => builder.AddDotnetProject("consumer", "consumer.csproj", options => options.ExcludeLaunchProfile = true),
            _ => throw new ArgumentOutOfRangeException(nameof(resourceType))
        };
 
        // Modern projects already wait for their build resource; authentication references must preserve those waits.
        var initialWaits = consumer.Resource.Annotations.OfType<WaitAnnotation>().ToArray();
        Assert.Same(consumer, consumer.WithReference(entra));
        consumer.WithReference(entra, connectionName: "OTHER");
 
        Assert.Equal(initialWaits, consumer.Resource.Annotations.OfType<WaitAnnotation>());
    }
 
    [Fact]
    public void WithReference_PreservesExplicitWaitBehavior()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-web");
        var node = builder.AddExecutable("node", "node", ".")
            .WaitFor(entra, WaitBehavior.StopOnResourceUnavailable)
            .WithReference(entra);
 
        var wait = Assert.Single(node.Resource.Annotations.OfType<WaitAnnotation>());
        Assert.Same(entra.Resource, wait.Resource);
        Assert.Equal(WaitBehavior.StopOnResourceUnavailable, wait.WaitBehavior);
    }
 
    [Fact]
    public async Task WithReference_EnvironmentResolutionPropagatesProvisioningFailure()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-web");
        var node = builder.AddExecutable("node", "node", ".")
            .WithReference(entra);
 
        using var app = builder.Build();
        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        var failure = new InvalidOperationException("Deployment failed.");
        var environmentTask = EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            node.Resource, DistributedApplicationOperation.Run, app.Services).AsTask();
        Assert.False(environmentTask.IsCompleted);
        entra.Resource.ProvisioningTaskCompletionSource!.SetException(failure);
 
        var exception = await Assert.ThrowsAsync<AggregateException>(() => environmentTask.WaitAsync(cts.Token));
        Assert.Collection(exception.InnerExceptions,
            error => Assert.Same(failure, error),
            error => Assert.Same(failure, error));
    }
 
    [Fact]
    public async Task ClientIdOutputReference_WaitsForProvisioning()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-web");
        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        var value = entra.Resource.ClientIdOutputReference.GetValueAsync(cts.Token).AsTask();
 
        Assert.False(value.IsCompleted);
        entra.Resource.Outputs["clientId"] = ClientId;
        entra.Resource.ProvisioningTaskCompletionSource!.SetResult();
 
        Assert.Equal(ClientId, await value);
    }
 
    [Fact]
    public async Task ClientIdOutputReference_CancelingAWaitDoesNotCancelProvisioning()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var entra = builder.AddEntraIdApplication("entra-web");
        using var cts = new CancellationTokenSource();
        var value = entra.Resource.ClientIdOutputReference.GetValueAsync(cts.Token).AsTask();
 
        cts.Cancel();
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => value);
        Assert.False(entra.Resource.ProvisioningTaskCompletionSource!.Task.IsCompleted);
        entra.Resource.Outputs["clientId"] = ClientId;
        entra.Resource.ProvisioningTaskCompletionSource.SetResult();
 
        Assert.Equal(ClientId, await entra.Resource.ClientIdOutputReference.GetValueAsync());
    }
}