File: EntraIdApiProvisioningTests.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 annotations are experimental.
 
using System.Runtime.CompilerServices;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using Aspire.Tests.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
 
namespace Aspire.Hosting.Azure.EntraId.Tests;
 
public class EntraIdApiProvisioningTests(ITestOutputHelper outputHelper)
{
    private const string TenantId = "aaaabbbb-0000-cccc-1111-dddd2222eeee";
    private const string ClientId = "00001111-aaaa-2222-bbbb-3333cccc4444";
    private const string ApiUri = "api://apiservice-entra";
    private const string ScopeDescription = "Allows the app to call the API on behalf of the signed-in user.";
 
    [Fact]
    public async Task WithScope_UsesDefaultApplicationIdUriSetBySecondDeclaration()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var api = builder.AddEntraIdApplication("entra-api")
            .WithRedirectUris("https://localhost:7123/signin-oidc")
            .WithScope("access_as_user", ScopeDescription);
 
        Assert.Null(api.Resource.ApplicationIdUri);
        await Verify(api.Resource.GetBicepTemplateString(), "bicep");
    }
 
    [Fact]
    public async Task WithApplicationIdUri_Default_ExposesApiWithoutScopes()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var api = builder.AddEntraIdApplication("entra-api").WithApplicationIdUri();
 
        await Verify(api.Resource.GetBicepTemplateString(), "bicep");
    }
 
    [Fact]
    public async Task WithApplicationIdUri_Custom_IsWrittenToTheApplicationWithoutASecondDeclaration()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var api = builder.AddEntraIdApplication("entra-api")
            .WithApplicationIdUri("api://contoso-orders")
            .WithScope("orders.read", "Read orders.", scope =>
            {
                scope.DisplayName = "Read orders";
                scope.RequiresAdminConsent = true;
                scope.UserConsentDisplayName = "Read your orders";
                scope.UserConsentDescription = "Allows the app to read your orders.";
            })
            .WithScope("orders.write", "Write orders.", scope => scope.IsEnabled = false);
 
        await Verify(api.Resource.GetBicepTemplateString(), "bicep");
    }
 
    [Fact]
    public void DefaultProvisioning_DoesNotExposeAnApi()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var bicep = builder.AddEntraIdApplication("entra-api").Resource.GetBicepTemplateString();
 
        Assert.DoesNotContain("identifierUris", bicep);
        Assert.DoesNotContain("oauth2PermissionScopes", bicep);
        Assert.DoesNotContain("applicationIdUri", bicep);
        Assert.DoesNotContain("requiredResourceAccess", bicep);
        Assert.DoesNotContain("oauth2PermissionGrants", bicep);
    }
 
    [Fact]
    public void GetBicepTemplateString_IsIdempotent()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var api = builder.AddEntraIdApplication("entra-api").WithScope("access_as_user", ScopeDescription);
        var web = builder.AddEntraIdApplication("entra-web")
            .WithApiPermissions(api, "access_as_user")
            .WithApiPermissions(EntraIdApi.MicrosoftGraph, EntraIdApiPermission.Delegated("User.Read"))
            .WithAdminConsent(EntraIdApi.MicrosoftGraph, EntraIdApiPermission.Delegated("User.Read"));
 
        Assert.Equal(api.Resource.GetBicepTemplateString(), api.Resource.GetBicepTemplateString());
        Assert.Equal(web.Resource.GetBicepTemplateString(), web.Resource.GetBicepTemplateString());
    }
 
    [Fact]
    public void ScopeIds_AreStableAcrossBuildersAndDistinctPerScope()
    {
        static Guid Resolve(string resourceName, string scopeName)
        {
            using var builder = TestDistributedApplicationBuilder.Create();
            var api = builder.AddEntraIdApplication(resourceName).WithScope(scopeName, ScopeDescription);
            return api.Resource.GetScopeId(Assert.Single(api.Resource.Scopes));
        }
 
        var id = Resolve("entra-api", "access_as_user");
 
        Assert.Equal(id, Resolve("entra-api", "access_as_user"));
        Assert.NotEqual(id, Resolve("entra-api", "other"));
        Assert.NotEqual(id, Resolve("other-api", "access_as_user"));
 
        // The ID is a version 8 GUID in the standard variant, as Graph requires GUID-shaped scope IDs.
        var text = id.ToString("D");
        Assert.Equal('8', text[14]);
        Assert.Contains(text[19], "89ab");
    }
 
    [Fact]
    public void ScopeIds_AreStableAcrossChangedScopeText()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var first = builder.AddEntraIdApplication("entra-api").WithScope("access_as_user", "Original description.");
        var second = builder.AddEntraIdApplication("entra-api2").WithScope("access_as_user", "Changed description.");
        var id = first.Resource.GetScopeId(first.Resource.Scopes[0]);
 
        first.Resource.Scopes[0].Description = "A different description.";
 
        Assert.Equal(id, first.Resource.GetScopeId(first.Resource.Scopes[0]));
        Assert.NotEqual(id, second.Resource.GetScopeId(second.Resource.Scopes[0]));
    }
 
    [Fact]
    public void WithScope_UsesExplicitId()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var id = Guid.Parse("11112222-aaaa-3333-bbbb-4444cccc5555");
        var api = builder.AddEntraIdApplication("entra-api").WithScope("access_as_user", ScopeDescription, scope => scope.Id = id);
 
        Assert.Equal(id, api.Resource.GetScopeId(Assert.Single(api.Resource.Scopes)));
        Assert.Contains("11112222-aaaa-3333-bbbb-4444cccc5555", api.Resource.GetBicepTemplateString());
    }
 
    [Theory]
    [InlineData("")]
    [InlineData("has space")]
    [InlineData(".read")]
    [InlineData(".default")]
    [InlineData("é")]
    public void WithScope_RejectsInvalidNames(string name)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var api = builder.AddEntraIdApplication("entra-api");
 
        Assert.ThrowsAny<ArgumentException>(() => api.WithScope(name, ScopeDescription));
        Assert.Empty(api.Resource.Scopes);
    }
 
    [Fact]
    public void WithScope_RejectsNamesLongerThanGraphAllows()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var api = builder.AddEntraIdApplication("entra-api");
 
        api.WithScope(new string('a', 120), ScopeDescription);
        Assert.Throws<ArgumentException>(() => api.WithScope(new string('b', 121), ScopeDescription));
    }
 
    [Fact]
    public void WithScope_RejectsDuplicateNameAndId()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var id = Guid.Parse("11112222-aaaa-3333-bbbb-4444cccc5555");
        var api = builder.AddEntraIdApplication("entra-api").WithScope("access_as_user", ScopeDescription, scope => scope.Id = id);
 
        Assert.Throws<ArgumentException>(() => api.WithScope("ACCESS_AS_USER", ScopeDescription));
        Assert.Throws<ArgumentException>(() => api.WithScope("other", ScopeDescription, scope => scope.Id = id));
        Assert.Single(api.Resource.Scopes);
    }
 
    [Theory]
    [InlineData("access_as_user")]
    [InlineData("Files.Read.All")]
    [InlineData("orders/read")]
    [InlineData("a:b-c~d")]
    public void WithScope_AcceptsTheCharactersGraphAllows(string name)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var api = builder.AddEntraIdApplication("entra-api").WithScope(name, ScopeDescription);
 
        Assert.Equal(name, Assert.Single(api.Resource.Scopes).Name);
    }
 
    [Theory]
    [InlineData("not a uri")]
    [InlineData("relative/path")]
    [InlineData("/orders")]
    [InlineData("file:///orders")]
    [InlineData("api://contoso?x=1")]
    [InlineData("api://contoso#fragment")]
    public void WithApplicationIdUri_RejectsInvalidUris(string uri)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var api = builder.AddEntraIdApplication("entra-api");
 
        // A rooted path such as "/orders" parses as an absolute file URI on Linux and macOS, so it must be rejected on purpose.
        Assert.Throws<ArgumentException>(() => api.WithApplicationIdUri(uri));
        Assert.Null(api.Resource.ApplicationIdUri);
    }
 
    [Theory]
    [InlineData("api://contoso-orders")]
    [InlineData("https://contoso.onmicrosoft.com/orders")]
    [InlineData("urn:contoso:orders")]
    public void WithApplicationIdUri_AcceptsAbsoluteUris(string uri)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var api = builder.AddEntraIdApplication("entra-api").WithApplicationIdUri(uri);
 
        Assert.Equal(uri, api.Resource.ApplicationIdUri);
    }
 
    [Fact]
    public void WithApplicationIdUri_DefaultOverridesAnEarlierCustomUriInEitherOrder()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var customThenDefault = builder.AddEntraIdApplication("custom-then-default")
            .WithScope("access_as_user", ScopeDescription)
            .WithApplicationIdUri("api://custom")
            .WithApplicationIdUri();
        var defaultThenCustom = builder.AddEntraIdApplication("default-then-custom")
            .WithScope("access_as_user", ScopeDescription)
            .WithApplicationIdUri()
            .WithApplicationIdUri("api://custom");
 
        Assert.Null(customThenDefault.Resource.ApplicationIdUri);
        var customThenDefaultBicep = customThenDefault.Resource.GetBicepTemplateString();
        Assert.Contains("'api://${application.appId}'", customThenDefaultBicep);
        Assert.DoesNotContain("api://custom", customThenDefaultBicep);
 
        Assert.Equal("api://custom", defaultThenCustom.Resource.ApplicationIdUri);
        var defaultThenCustomBicep = defaultThenCustom.Resource.GetBicepTemplateString();
        Assert.Contains("'api://custom'", defaultThenCustomBicep);
        Assert.DoesNotContain("api://${application.appId}", defaultThenCustomBicep);
    }
 
    [Fact]
    public void WithApplicationIdUri_DefaultDoesNotClearTheUriOfAnExistingRegistration()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var existing = builder.AddExistingEntraApplication("existing-api", TenantId, ClientId)
            .WithApplicationIdUri("api://contoso-orders")
            .WithApplicationIdUri();
 
        Assert.Equal("api://contoso-orders", existing.Resource.ApplicationIdUri);
    }
 
    [Theory]
    [InlineData("User.Read Directory.Read.All")]
    [InlineData("User.Read\tDirectory.Read.All")]
    [InlineData(" User.Read")]
    [InlineData("")]
    [InlineData(".default")]
    public void EntraIdApiPermission_RejectsNamesThatAreNotValidScopeValues(string name)
    {
        // A name with a space would be written into the space-separated scope of a consent grant, granting a second, undeclared permission.
        var userRead = new Guid("e1fe6dd8-ba31-4d61-89e7-88639da4683d");
 
        Assert.ThrowsAny<ArgumentException>(() => EntraIdApiPermission.Delegated(name));
        Assert.ThrowsAny<ArgumentException>(() => EntraIdApiPermission.Delegated(name, userRead));
        Assert.ThrowsAny<ArgumentException>(() => EntraIdApiPermission.Application(name));
        Assert.ThrowsAny<ArgumentException>(() => EntraIdApiPermission.Application(name, userRead));
    }
 
    [Fact]
    public void WithApiPermissions_RejectsAnExplicitIdOfAnotherCatalogPermission()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var web = builder.AddEntraIdApplication("entra-web");
 
        // The ID is User.Read's. A grant would name "Typo.Read" while the declaration requests User.Read.
        var exception = Assert.Throws<ArgumentException>(() => web.WithApiPermissions(
            EntraIdApi.MicrosoftGraph,
            EntraIdApiPermission.Delegated("Typo.Read", new Guid("e1fe6dd8-ba31-4d61-89e7-88639da4683d"))));
        Assert.Contains("'User.Read'", exception.Message);
        Assert.Empty(web.Resource.ApiAccess);
 
        // The same ID is the application permission of a different name, so the delegated catalog doesn't object.
        web.WithApiPermissions(EntraIdApi.MicrosoftGraph, EntraIdApiPermission.Application("Custom.Role", new Guid("e1fe6dd8-ba31-4d61-89e7-88639da4683d")));
    }
 
    [Fact]
    public async Task WithReference_OnlyMicrosoftGraphExcludesTheOpenIdScopesFromDownstreamApis()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var contoso = EntraIdApi.FromApplicationId("Contoso API", "99998888-aaaa-7777-bbbb-6666cccc5555", "api://contoso");
        var profileOnly = EntraIdApi.FromApplicationId("Profile API", "11110000-aaaa-7777-bbbb-6666cccc5555", "api://profile");
        var web = builder.AddExistingEntraApplication("entra-web", TenantId, ClientId)
            .WithApiPermissions(EntraIdApi.MicrosoftGraph, EntraIdApiPermission.Delegated("openid"), EntraIdApiPermission.Delegated("User.Read"))
            .WithApiPermissions(contoso, EntraIdApiPermission.Delegated("profile", Guid.NewGuid()), EntraIdApiPermission.Delegated("email", Guid.NewGuid()))
            .WithApiPermissions(profileOnly, EntraIdApiPermission.Delegated("offline_access", Guid.NewGuid()));
        var project = builder.AddResource(new ProjectResource("web")).WithReference(web);
 
        var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(project.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        // Graph's OpenID Connect scopes are requested at sign-in; another API's scopes with the same names are its own.
        Assert.Equal("https://graph.microsoft.com/User.Read", Assert.Single(env, e => e.Key.StartsWith("DownstreamApis__MicrosoftGraph__Scopes", StringComparison.Ordinal)).Value);
        Assert.Equal("api://contoso/profile", env["DownstreamApis__ContosoAPI__Scopes__0"]);
        Assert.Equal("api://contoso/email", env["DownstreamApis__ContosoAPI__Scopes__1"]);
        Assert.Equal("api://profile/offline_access", env["DownstreamApis__ProfileAPI__Scopes__0"]);
    }
 
    [Fact]
    public async Task WithReference_UsesTheBaseUrlOfAnApiCreatedFromAnApplicationId()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var graph = EntraIdApi.FromApplicationId(
            "Microsoft Graph", "00000003-0000-0000-c000-000000000000", "https://graph.microsoft.us", "https://graph.microsoft.us/v1.0");
        var contoso = EntraIdApi.FromApplicationId("Contoso API", "99998888-aaaa-7777-bbbb-6666cccc5555", "api://contoso");
        var web = builder.AddExistingEntraApplication("entra-web", TenantId, ClientId)
            .WithApiPermissions(graph, EntraIdApiPermission.Delegated("User.Read", new Guid("e1fe6dd8-ba31-4d61-89e7-88639da4683d")))
            .WithApiPermissions(contoso, EntraIdApiPermission.Delegated("items.read", Guid.NewGuid()));
        var project = builder.AddResource(new ProjectResource("web")).WithReference(web);
 
        var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(project.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        Assert.Equal("https://graph.microsoft.us/User.Read", env["DownstreamApis__MicrosoftGraph__Scopes__0"]);
        Assert.Equal("https://graph.microsoft.us/v1.0", env["DownstreamApis__MicrosoftGraph__BaseUrl"]);
        Assert.Equal("api://contoso/items.read", env["DownstreamApis__ContosoAPI__Scopes__0"]);
        Assert.DoesNotContain("DownstreamApis__ContosoAPI__BaseUrl", env.Keys);
    }
 
    [Theory]
    [InlineData("")]
    [InlineData("graph.microsoft.us")]
    [InlineData("/v1.0")]
    [InlineData("ftp://graph.microsoft.us")]
    public void FromApplicationId_RejectsABaseUrlThatIsNotAnAbsoluteHttpUrl(string baseUrl)
    {
        var exception = Assert.Throws<ArgumentException>(
            () => EntraIdApi.FromApplicationId("Contoso API", "99998888-aaaa-7777-bbbb-6666cccc5555", "api://contoso", baseUrl));
 
        Assert.Equal("baseUrl", exception.ParamName);
    }
 
    [Fact]
    public async Task WithApiPermissions_Declares_ButDoesNotGrantOrConsent()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var api = builder.AddEntraIdApplication("entra-api").WithScope("access_as_user", ScopeDescription);
        var web = builder.AddEntraIdApplication("entra-web")
            .WithApiPermissions(api, "access_as_user")
            .WithApiPermissions(
                EntraIdApi.MicrosoftGraph,
                EntraIdApiPermission.Delegated("User.Read"),
                EntraIdApiPermission.Delegated("offline_access"),
                EntraIdApiPermission.Application("User.Read.All"));
 
        var bicep = web.Resource.GetBicepTemplateString();
 
        Assert.DoesNotContain("oauth2PermissionGrants", bicep);
        Assert.DoesNotContain("appRoleAssignedTo", bicep);
        await Verify(bicep, "bicep");
    }
 
    [Fact]
    public async Task WithAdminConsent_GrantsDelegatedScopesToAllUsersAndAppRolesToTheServicePrincipal()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var api = builder.AddEntraIdApplication("entra-api")
            .WithScope("access_as_user", ScopeDescription)
            .WithScope("other", "Another scope.");
        var web = builder.AddEntraIdApplication("entra-web")
            .WithApiPermissions(api, "access_as_user", "other")
            .WithAdminConsent(api, "access_as_user")
            .WithApiPermissions(
                EntraIdApi.MicrosoftGraph,
                EntraIdApiPermission.Delegated("User.Read"),
                EntraIdApiPermission.Delegated("User.Read.All"),
                EntraIdApiPermission.Application("User.Read.All"),
                EntraIdApiPermission.Application("Directory.Read.All"))
            .WithAdminConsent(
                EntraIdApi.MicrosoftGraph,
                EntraIdApiPermission.Delegated("User.Read"),
                EntraIdApiPermission.Application("User.Read.All"));
 
        await Verify(web.Resource.GetBicepTemplateString(), "bicep");
    }
 
    [Fact]
    public async Task WithApiPermissions_ExistingApi_UsesConfiguredScopeIdsAndClientId()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var clientIdParameter = builder.AddParameter("api-client-id");
        var existingApi = builder.AddExistingEntraApplication("existing-api", builder.AddParameter("api-tenant-id"), clientIdParameter)
            .WithApplicationIdUri("api://contoso-orders")
            .WithScope("orders.read", "Read orders.", scope => scope.Id = Guid.Parse("11112222-aaaa-3333-bbbb-4444cccc5555"));
        var literalApi = builder.AddExistingEntraApplication("literal-api", TenantId, ClientId)
            .WithScope("items.read", "Read items.", scope => scope.Id = Guid.Parse("22223333-aaaa-4444-bbbb-5555cccc6666"));
        var web = builder.AddEntraIdApplication("entra-web")
            .WithApiPermissions(existingApi, "orders.read")
            .WithApiPermissions(literalApi, "items.read")
            .WithAdminConsent(existingApi, "orders.read");
 
        await Verify(web.Resource.GetBicepTemplateString(), "bicep");
    }
 
    [Fact]
    public void ExistingApplication_ExposedApiIsConfigurationOnly()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var existingApi = builder.AddExistingEntraApplication("existing-api", TenantId, ClientId)
            .WithApplicationIdUri("api://contoso-orders")
            .WithScope("orders.read", "Read orders.", scope => scope.Id = Guid.NewGuid());
 
        Assert.Equal("api://contoso-orders", existingApi.Resource.ApplicationIdUri);
        Assert.Throws<InvalidOperationException>(existingApi.Resource.GetBicepTemplateString);
    }
 
    [Fact]
    public void WithApiPermissions_ExistingApiScopeIdCanBeSetAfterTheDeclaration()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var existingApi = builder.AddExistingEntraApplication("existing-api", TenantId, ClientId)
            .WithScope("orders.read", "Read orders.");
        var web = builder.AddEntraIdApplication("entra-web");
 
        // The ID of an existing registration's scope can't be derived, but it is only needed when the template is generated,
        // so it can be supplied after the permission is declared.
        web.WithApiPermissions(existingApi, "orders.read");
        var exception = Assert.Throws<InvalidOperationException>(web.Resource.GetBicepTemplateString);
        Assert.Contains("EntraIdScope.Id", exception.Message);
 
        var id = Guid.Parse("11112222-aaaa-3333-bbbb-4444cccc5555");
        existingApi.Resource.Scopes[0].Id = id;
        Assert.Contains($"id: '{id:D}'", web.Resource.GetBicepTemplateString());
    }
    [Fact]
    public void WithApiPermissions_ValidatesScopes()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var api = builder.AddEntraIdApplication("entra-api")
            .WithScope("access_as_user", ScopeDescription)
            .WithScope("retired", "Retired.", scope => scope.IsEnabled = false);
        var web = builder.AddEntraIdApplication("entra-web");
 
        Assert.Throws<ArgumentException>(() => web.WithApiPermissions(api));
        Assert.Throws<ArgumentException>(() => web.WithApiPermissions(api, "unknown"));
        Assert.Throws<ArgumentException>(() => web.WithApiPermissions(api, "ACCESS_AS_USER"));
        Assert.Throws<ArgumentException>(() => web.WithApiPermissions(api, "retired"));
        Assert.Throws<ArgumentException>(() => api.WithApiPermissions(api, "access_as_user"));
        Assert.Empty(web.Resource.ApiAccess);
    }
 
    [Fact]
    public void ScopeDisabledAfterItWasDeclared_IsRejectedWhenTheCallersTemplateIsGenerated()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var api = builder.AddEntraIdApplication("entra-api").WithScope("access_as_user", ScopeDescription);
        var web = builder.AddEntraIdApplication("entra-web")
            .WithApiPermissions(api, "access_as_user")
            .WithAdminConsent(api, "access_as_user");
 
        // The scope was enabled when it was declared, so the declaration was accepted.
        Assert.Contains("access_as_user", web.Resource.GetBicepTemplateString());
 
        api.Resource.Scopes[0].IsEnabled = false;
 
        var template = Assert.Throws<InvalidOperationException>(web.Resource.GetBicepTemplateString);
        Assert.Contains("'entra-web' declares permissions to the scope 'access_as_user' of 'entra-api'", template.Message);
        Assert.Contains("disabled", template.Message);
        var validation = Assert.Throws<InvalidOperationException>(web.Resource.ValidateApiAccess);
        Assert.Equal(template.Message, validation.Message);
    }
 
    [Fact]
    public void ScopeDisabledAfterItWasPreAuthorized_IsRejectedWhenTheApisTemplateIsGenerated()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var web = builder.AddEntraIdApplication("entra-web");
        var api = builder.AddEntraIdApplication("entra-api")
            .WithScope("access_as_user", ScopeDescription)
            .WithPreAuthorizedApplication(web, "access_as_user");
 
        Assert.Contains("preAuthorizedApplications", api.Resource.GetBicepTemplateString());
 
        api.Resource.Scopes[0].IsEnabled = false;
 
        var template = Assert.Throws<InvalidOperationException>(api.Resource.GetBicepTemplateString);
        Assert.Contains("'entra-api' pre-authorizes 'entra-web' for the scope 'access_as_user'", template.Message);
        Assert.Contains("disabled", template.Message);
        Assert.Throws<InvalidOperationException>(api.Resource.ValidateApiAccess);
    }
 
    [Fact]
    public void ScopeDisabledAfterItWasDeclared_IsRejectedForAnExistingApi()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var existingApi = builder.AddExistingEntraApplication("existing-api", TenantId, ClientId)
            .WithApplicationIdUri("api://contoso-items")
            .WithScope("items.read", "Read items.", scope => scope.Id = Guid.NewGuid());
        var web = builder.AddExistingEntraApplication("entra-web", TenantId, ClientId)
            .WithApiPermissions(existingApi, "items.read");
 
        web.Resource.ValidateApiAccess();
 
        existingApi.Resource.Scopes[0].IsEnabled = false;
 
        var validation = Assert.Throws<InvalidOperationException>(web.Resource.ValidateApiAccess);
        Assert.Contains("'entra-web' declares permissions to the scope 'items.read' of 'existing-api'", validation.Message);
    }
 
    [Fact]
    public void WithApiPermissions_DeduplicatesPermissions()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var web = builder.AddEntraIdApplication("entra-web")
            .WithApiPermissions(EntraIdApi.MicrosoftGraph, EntraIdApiPermission.Delegated("User.Read"))
            .WithApiPermissions(EntraIdApi.MicrosoftGraph, EntraIdApiPermission.Delegated("user.read"), EntraIdApiPermission.Delegated("User.Read", new Guid("e1fe6dd8-ba31-4d61-89e7-88639da4683d")));
 
        var access = Assert.Single(web.Resource.ApiAccess);
        var permission = Assert.Single(access.Declared);
        Assert.Equal("User.Read", permission.Name);
    }
 
    [Fact]
    public void WithApiPermissions_DelegatedAndApplicationPermissionsWithTheSameNameStayDistinct()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var web = builder.AddEntraIdApplication("entra-web")
            .WithApiPermissions(
                EntraIdApi.MicrosoftGraph,
                EntraIdApiPermission.Delegated("User.Read.All"),
                EntraIdApiPermission.Application("User.Read.All"));
 
        var declared = Assert.Single(web.Resource.ApiAccess).Declared;
        Assert.Equal(2, declared.Count);
        Assert.Equal(Guid.Parse("a154be20-db9c-4678-8ab7-66f6cc099a59"), Assert.Single(declared, p => p.Type == EntraIdPermissionType.Delegated).Id);
        Assert.Equal(Guid.Parse("df021288-bdef-4463-88db-98f22de89214"), Assert.Single(declared, p => p.Type == EntraIdPermissionType.Application).Id);
    }
 
    [Fact]
    public void WithApiPermissions_RejectsUnknownPermissionsAndMismatchedIds()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var web = builder.AddEntraIdApplication("entra-web");
 
        var unknown = Assert.Throws<ArgumentException>(() =>
            web.WithApiPermissions(EntraIdApi.MicrosoftGraph, EntraIdApiPermission.Delegated("Not.A.Permission")));
        Assert.Contains("Not.A.Permission", unknown.Message);
        Assert.Contains("User.Read", unknown.Message);
 
        // The application permission exists only as a delegated one in the catalog's ARM entry.
        Assert.Throws<ArgumentException>(() =>
            web.WithApiPermissions(EntraIdApi.AzureResourceManager, EntraIdApiPermission.Application("user_impersonation")));
 
        var mismatch = Assert.Throws<ArgumentException>(() =>
            web.WithApiPermissions(EntraIdApi.MicrosoftGraph, EntraIdApiPermission.Delegated("User.Read", Guid.NewGuid())));
        Assert.Contains("e1fe6dd8-ba31-4d61-89e7-88639da4683d", mismatch.Message);
 
        Assert.Throws<ArgumentException>(() => web.WithApiPermissions(EntraIdApi.MicrosoftGraph));
        Assert.Throws<ArgumentException>(() => EntraIdApiPermission.Delegated("User.Read", Guid.Empty));
        Assert.Empty(web.Resource.ApiAccess);
    }
 
    [Fact]
    public async Task WithApiPermissions_AcceptsPermissionsOutsideTheCatalogById()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var mailSend = Guid.Parse("b633e1c5-b582-4048-a93e-9f11b44c7e96");
        var web = builder.AddEntraIdApplication("entra-web")
            .WithApiPermissions(EntraIdApi.MicrosoftGraph, EntraIdApiPermission.Application("Mail.Send", mailSend))
            .WithApiPermissions(
                EntraIdApi.FromApplicationId("Contoso API", "99998888-aaaa-7777-bbbb-6666cccc5555", "api://contoso"),
                EntraIdApiPermission.Delegated("Contoso.Read", Guid.Parse("33334444-aaaa-5555-bbbb-6666cccc7777")))
            .WithAdminConsent(
                EntraIdApi.FromApplicationId("Contoso API", "99998888-aaaa-7777-bbbb-6666cccc5555", "api://contoso"),
                EntraIdApiPermission.Delegated("Contoso.Read", Guid.Parse("33334444-aaaa-5555-bbbb-6666cccc7777")));
 
        await Verify(web.Resource.GetBicepTemplateString(), "bicep");
    }
 
    [Fact]
    public void EntraIdApi_FromApplicationId_RequiresIdsAndAGuid()
    {
        var custom = EntraIdApi.FromApplicationId("Contoso API", "99998888-AAAA-7777-bbbb-6666cccc5555", "api://contoso");
 
        Assert.Equal("99998888-aaaa-7777-bbbb-6666cccc5555", custom.ApplicationId);
        Assert.Throws<ArgumentException>(() => EntraIdApi.FromApplicationId("Contoso API", "not-a-guid", "api://contoso"));
 
        using var builder = TestDistributedApplicationBuilder.Create();
        var web = builder.AddEntraIdApplication("entra-web");
        var exception = Assert.Throws<ArgumentException>(() => web.WithApiPermissions(custom, EntraIdApiPermission.Delegated("Contoso.Read")));
        Assert.Contains("Contoso.Read", exception.Message);
    }
 
    [Fact]
    public void WellKnownApis_HaveStableApplicationIds()
    {
        Assert.Equal("00000003-0000-0000-c000-000000000000", EntraIdApi.MicrosoftGraph.ApplicationId);
        Assert.Equal("797f4846-ba00-4fd7-ba43-dac1f8f63013", EntraIdApi.AzureResourceManager.ApplicationId);
        Assert.Equal("e406a681-f3d4-42a8-90b6-c2b029497af1", EntraIdApi.AzureStorage.ApplicationId);
    }
 
    [Fact]
    public void WithAdminConsent_RequiresTheSamePermissionToBeDeclaredFirst()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var api = builder.AddEntraIdApplication("entra-api").WithScope("access_as_user", ScopeDescription);
        var web = builder.AddEntraIdApplication("entra-web")
            .WithApiPermissions(EntraIdApi.MicrosoftGraph, EntraIdApiPermission.Delegated("User.Read"));
 
        Assert.Throws<InvalidOperationException>(() => web.WithAdminConsent(api, "access_as_user"));
        Assert.Throws<InvalidOperationException>(() => web.WithAdminConsent(EntraIdApi.MicrosoftGraph, EntraIdApiPermission.Delegated("User.Read.All")));
 
        // Declaring a delegated permission does not declare the application permission with the same name.
        Assert.Throws<InvalidOperationException>(() => web.WithAdminConsent(EntraIdApi.MicrosoftGraph, EntraIdApiPermission.Application("Mail.Read")));
        Assert.Empty(Assert.Single(web.Resource.ApiAccess).Consented);
    }
 
    [Fact]
    public async Task WithPreAuthorizedApplication_WritesClientIdsAndScopeIds()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var web = builder.AddEntraIdApplication("entra-web");
        var existingClient = builder.AddExistingEntraApplication("existing-client", TenantId, ClientId);
        var api = builder.AddEntraIdApplication("entra-api")
            .WithScope("access_as_user", ScopeDescription)
            .WithScope("other", "Another scope.")
            .WithPreAuthorizedApplication(web, "access_as_user")
            .WithPreAuthorizedApplication(web, "other")
            .WithPreAuthorizedApplication(existingClient, "access_as_user");
 
        Assert.Equal(2, api.Resource.PreAuthorizedClients.Count);
        await Verify(api.Resource.GetBicepTemplateString(), "bicep");
    }
 
    [Fact]
    public void WithPreAuthorizedApplication_ValidatesScopes()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var web = builder.AddEntraIdApplication("entra-web");
        var api = builder.AddEntraIdApplication("entra-api")
            .WithScope("access_as_user", ScopeDescription)
            .WithScope("retired", "Retired.", scope => scope.IsEnabled = false);
 
        Assert.Throws<ArgumentException>(() => api.WithPreAuthorizedApplication(web));
        Assert.Throws<ArgumentException>(() => api.WithPreAuthorizedApplication(web, "unknown"));
        Assert.Throws<ArgumentException>(() => api.WithPreAuthorizedApplication(web, "retired"));
        Assert.Empty(api.Resource.PreAuthorizedClients);
    }
 
    [Fact]
    public void PreAuthorizationAndDeclarationBetweenTheSameRegistrationsCannotBothBeUsed()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var api = builder.AddEntraIdApplication("entra-api").WithScope("access_as_user", ScopeDescription);
        var web = builder.AddEntraIdApplication("entra-web").WithApiPermissions(api, "access_as_user");
 
        var exception = Assert.Throws<InvalidOperationException>(() => api.WithPreAuthorizedApplication(web, "access_as_user"));
        Assert.Contains("not both", exception.Message);
        Assert.Empty(api.Resource.PreAuthorizedClients);
 
        var other = builder.AddEntraIdApplication("other-api").WithScope("access_as_user", ScopeDescription);
        var otherWeb = builder.AddEntraIdApplication("other-web");
        other.WithPreAuthorizedApplication(otherWeb, "access_as_user");
        Assert.Throws<InvalidOperationException>(() => otherWeb.WithApiPermissions(other, "access_as_user"));
        Assert.Empty(otherWeb.Resource.ApiAccess);
    }
 
    [Fact]
    public void DependencyCyclesThroughOtherRegistrationsAreRejected()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var a = builder.AddEntraIdApplication("a").WithScope("scope", ScopeDescription);
        var b = builder.AddEntraIdApplication("b").WithScope("scope", ScopeDescription);
        var c = builder.AddEntraIdApplication("c").WithScope("scope", ScopeDescription);
        a.WithApiPermissions(b, "scope");
        b.WithApiPermissions(c, "scope");
 
        Assert.Throws<InvalidOperationException>(() => c.WithApiPermissions(a, "scope"));
        Assert.Throws<InvalidOperationException>(() => c.WithPreAuthorizedApplication(a, "scope"));
    }
 
    [Fact]
    public async Task ExistingRegistrationsDoNotCreateDependencyCycles()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var existingWeb = builder.AddExistingEntraApplication("existing-web", TenantId, ClientId);
        var api = builder.AddEntraIdApplication("entra-api")
            .WithScope("access_as_user", ScopeDescription)
            .WithPreAuthorizedApplication(existingWeb, "access_as_user");
 
        // The existing registration deploys nothing, so it waits for nothing and the API's module can depend on its client ID.
        existingWeb.WithApiPermissions(api, "access_as_user");
        await Verify(api.Resource.GetBicepTemplateString(), "bicep");
    }
 
    [Fact]
    public void GetScopeUri_BuildsTheFullScopeFromTheApplicationIdUri()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var created = builder.AddEntraIdApplication("created-api").WithScope("access_as_user", ScopeDescription);
        var custom = builder.AddEntraIdApplication("custom-api").WithApplicationIdUri("api://contoso-orders").WithScope("orders.read", "Read orders.");
        var existing = builder.AddExistingEntraApplication("existing-api", TenantId, ClientId).WithApplicationIdUri("api://existing");
        existing.WithScope("items.read", "Read items.", scope => scope.Id = Guid.NewGuid());
        var existingWithoutUri = builder.AddExistingEntraApplication("no-uri-api", TenantId, ClientId);
        existingWithoutUri.WithScope("items.read", "Read items.", scope => scope.Id = Guid.NewGuid());
 
        Assert.Equal("{created-api.outputs.applicationIdUri}/access_as_user", created.Resource.GetScopeUri("access_as_user").ValueExpression);
        Assert.Equal("api://contoso-orders/orders.read", custom.Resource.GetScopeUri("orders.read").ValueExpression);
        Assert.Equal("api://existing/items.read", existing.Resource.GetScopeUri("items.read").ValueExpression);
        Assert.Throws<ArgumentException>(() => created.Resource.GetScopeUri("unknown"));
        Assert.Throws<InvalidOperationException>(() => existingWithoutUri.Resource.GetScopeUri("items.read"));
    }
 
    [Fact]
    public async Task Publish_WiresApiOutputsIntoTheCallerModuleAndPreAuthorizedClientsIntoTheApiModule()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputHelper, workspace.Path);
        var api = builder.AddEntraIdApplication("entra-api")
            .WithScope("access_as_user", ScopeDescription);
        var web = builder.AddEntraIdApplication("entra-web")
            .WithApiPermissions(api, "access_as_user")
            .WithAdminConsent(api, "access_as_user")
            .WithApiPermissions(EntraIdApi.MicrosoftGraph, EntraIdApiPermission.Delegated("User.Read"));
        var daemon = builder.AddEntraIdApplication("entra-daemon");
        api.WithPreAuthorizedApplication(daemon, "access_as_user");
 
        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);
 
        await Verify(await File.ReadAllTextAsync(Path.Combine(workspace.Path, "main.bicep")), "bicep")
            .AppendContentAsFile(await File.ReadAllTextAsync(Path.Combine(workspace.Path, "entra-api", "entra-api.bicep")), "bicep")
            .AppendContentAsFile(await File.ReadAllTextAsync(Path.Combine(workspace.Path, "entra-web", "entra-web.bicep")), "bicep");
    }
 
    [Theory]
    [InlineData(DistributedApplicationOperation.Run)]
    [InlineData(DistributedApplicationOperation.Publish)]
    public async Task WithReference_FlowsDeclaredApiScopesToDownstreamApis(DistributedApplicationOperation operation)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var api = builder.AddEntraIdApplication("entra-api").WithScope("access_as_user", ScopeDescription);
        var web = builder.AddEntraIdApplication("entra-web").WithApiPermissions(api, "access_as_user");
        foreach (var (entra, prefix) in new[] { (api, "api"), (web, "web") })
        {
            entra.Resource.Outputs["tenantId"] = TenantId;
            entra.Resource.Outputs["clientId"] = ClientId;
            entra.Resource.Outputs["applicationIdUri"] = $"api://{prefix}-{ClientId}";
            entra.Resource.ProvisioningTaskCompletionSource!.SetResult();
        }
 
        builder.AddResource(new ProjectResource("apiservice")).WithReference(api);
        var project = builder.AddResource(new ProjectResource("web")).WithReference(web);
 
        var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(project.Resource, operation, TestServiceProvider.Instance);
 
        Assert.Equal(
            operation == DistributedApplicationOperation.Run ? $"api://api-{ClientId}/access_as_user" : "{entra-api.outputs.applicationIdUri}/access_as_user",
            Assert.Single(env, e => e.Key.StartsWith("DownstreamApis__", StringComparison.Ordinal)).Value);
        Assert.Equal("DownstreamApis__apiservice__Scopes__0", Assert.Single(env, e => e.Key.StartsWith("DownstreamApis__", StringComparison.Ordinal)).Key);
        Assert.DoesNotContain("AzureAd__Scopes", env.Keys);
    }
 
    [Fact]
    public async Task WithReference_FlowsFirstPartyApiPermissionsAsDelegatedAndAppOnlyEntries()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var web = builder.AddExistingEntraApplication("entra-web", TenantId, ClientId)
            .WithApiPermissions(
                EntraIdApi.MicrosoftGraph,
                EntraIdApiPermission.Delegated("openid"),
                EntraIdApiPermission.Delegated("User.Read"),
                EntraIdApiPermission.Delegated("Mail.Read"),
                EntraIdApiPermission.Application("User.Read.All"))
            .WithApiPermissions(EntraIdApi.AzureStorage, EntraIdApiPermission.Delegated("user_impersonation"));
        var project = builder.AddResource(new ProjectResource("web")).WithReference(web);
        var container = builder.AddContainer("worker", "myimage").WithReference(web, connectionName: "ENTRA");
 
        var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(project.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
        var containerEnv = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(container.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        Assert.Equal(new Dictionary<string, string>
        {
            ["DownstreamApis__MicrosoftGraph__Scopes__0"] = "https://graph.microsoft.com/User.Read",
            ["DownstreamApis__MicrosoftGraph__Scopes__1"] = "https://graph.microsoft.com/Mail.Read",
            ["DownstreamApis__MicrosoftGraph__BaseUrl"] = "https://graph.microsoft.com/v1.0",
            ["DownstreamApis__MicrosoftGraphAppOnly__Scopes__0"] = "https://graph.microsoft.com/.default",
            ["DownstreamApis__MicrosoftGraphAppOnly__BaseUrl"] = "https://graph.microsoft.com/v1.0",
            ["DownstreamApis__MicrosoftGraphAppOnly__RequestAppToken"] = "true",
            ["DownstreamApis__AzureStorage__Scopes__0"] = "https://storage.azure.com/user_impersonation"
        }, env.Where(e => e.Key.StartsWith("DownstreamApis__", StringComparison.Ordinal)).ToDictionary());
        Assert.Equal(new Dictionary<string, string>
        {
            ["ENTRA_DOWNSTREAM_APIS_MICROSOFT_GRAPH_SCOPES_0"] = "https://graph.microsoft.com/User.Read",
            ["ENTRA_DOWNSTREAM_APIS_MICROSOFT_GRAPH_SCOPES_1"] = "https://graph.microsoft.com/Mail.Read",
            ["ENTRA_DOWNSTREAM_APIS_MICROSOFT_GRAPH_BASE_URL"] = "https://graph.microsoft.com/v1.0",
            ["ENTRA_DOWNSTREAM_APIS_MICROSOFT_GRAPH_APP_ONLY_SCOPES_0"] = "https://graph.microsoft.com/.default",
            ["ENTRA_DOWNSTREAM_APIS_MICROSOFT_GRAPH_APP_ONLY_BASE_URL"] = "https://graph.microsoft.com/v1.0",
            ["ENTRA_DOWNSTREAM_APIS_MICROSOFT_GRAPH_APP_ONLY_REQUEST_APP_TOKEN"] = "true",
            ["ENTRA_DOWNSTREAM_APIS_AZURE_STORAGE_SCOPES_0"] = "https://storage.azure.com/user_impersonation"
        }, containerEnv.Where(e => e.Key.StartsWith("ENTRA_DOWNSTREAM", StringComparison.Ordinal)).ToDictionary());
    }
 
    [Fact]
    public async Task WithReference_FlowsScopesOfExistingApisUsingTheirConfiguredApplicationIdUri()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var orders = builder.AddExistingEntraApplication("orders-entra", TenantId, ClientId)
            .WithApplicationIdUri("api://contoso-orders")
            .WithScope("orders.read", "Read orders.", scope => scope.Id = Guid.NewGuid());
        var items = builder.AddExistingEntraApplication("items-entra", TenantId, ClientId)
            .WithApplicationIdUri("https://contoso.onmicrosoft.com/items")
            .WithScope("items.read", "Read items.", scope => scope.Id = Guid.NewGuid());
        var web = builder.AddEntraIdApplication("entra-web")
            .WithApiPermissions(orders, "orders.read")
            .WithApiPermissions(items, "items.read");
        web.Resource.Outputs["tenantId"] = TenantId;
        web.Resource.Outputs["clientId"] = ClientId;
        web.Resource.ProvisioningTaskCompletionSource!.SetResult();
        builder.AddResource(new ProjectResource("orders-svc")).WithReference(orders);
        builder.AddResource(new ProjectResource("items-svc")).WithReference(items);
        var project = builder.AddResource(new ProjectResource("web")).WithReference(web);
 
        var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(project.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        // The consumer's name is used as is, like the resource name in service discovery.
        Assert.Equal("api://contoso-orders/orders.read", env["DownstreamApis__orders-svc__Scopes__0"]);
        Assert.Equal("https://contoso.onmicrosoft.com/items/items.read", env["DownstreamApis__items-svc__Scopes__0"]);
    }
 
    [Fact]
    public async Task WithReference_RejectsAnExistingApiWithoutAnApplicationIdUri()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var existingApi = builder.AddExistingEntraApplication("without-uri", TenantId, ClientId)
            .WithScope("items.read", "Read items.", scope => scope.Id = Guid.NewGuid());
        var web = builder.AddExistingEntraApplication("entra-web", TenantId, ClientId)
            .WithApiPermissions(existingApi, "items.read");
 
        // An existing registration can use any URI, so a made-up api://{clientId} would only fail later, as a token error.
        // Nothing references the client yet, so there is nothing to configure and nothing to reject.
        web.Resource.ValidateApiAccess();
 
        var project = builder.AddResource(new ProjectResource("web")).WithReference(web);
 
        var validation = Assert.Throws<InvalidOperationException>(web.Resource.ValidateApiAccess);
        Assert.Contains("'entra-web'", validation.Message);
        Assert.Contains("WithApplicationIdUri on 'without-uri'", validation.Message);
        var environment = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
            await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(project.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance));
        Assert.Equal(validation.Message, environment.Message);
 
        // Setting the URI, in any order, resolves it.
        existingApi.WithApplicationIdUri("api://contoso-items");
        web.Resource.ValidateApiAccess();
    }
 
    [Fact]
    public async Task ValidateApiAccess_RunsBeforeStartForCreatedAndExistingRegistrationsThatDeclarePermissions()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var api = builder.AddEntraIdApplication("api-entra").WithScope("access_as_user", ScopeDescription);
        var created = builder.AddEntraIdApplication("created-web");
        var existing = builder.AddExistingEntraApplication("existing-web", TenantId, ClientId);
        var existingWithoutDeclarations = builder.AddExistingEntraApplication("existing-other", TenantId, ClientId);
 
        // A registration that declares nothing has nothing to validate, and an existing one keeps no pipeline steps.
        Assert.Empty(existing.Resource.Annotations.OfType<PipelineStepAnnotation>());
        Assert.Empty(existingWithoutDeclarations.Resource.Annotations.OfType<PipelineStepAnnotation>());
 
        created.WithApiPermissions(api, "access_as_user");
        existing.WithApiPermissions(EntraIdApi.MicrosoftGraph, EntraIdApiPermission.Delegated("User.Read"));
        existing.WithApiPermissions(EntraIdApi.AzureStorage, EntraIdApiPermission.Delegated("user_impersonation"));
 
        foreach (var resource in new[] { created.Resource, existing.Resource })
        {
            var annotation = Assert.Single(resource.Annotations.OfType<PipelineStepAnnotation>(), IsApiAccessValidation);
            var step = Assert.Single(await annotation.CreateStepsAsync(new PipelineStepFactoryContext { PipelineContext = null!, Resource = resource }));
            Assert.Equal([WellKnownPipelineSteps.BeforeStart], step.RequiredBySteps);
 
            bool IsApiAccessValidation(PipelineStepAnnotation a) =>
                a.CreateStepsAsync(new PipelineStepFactoryContext { PipelineContext = null!, Resource = resource }).GetAwaiter().GetResult()
                    .Any(s => s.Name == $"validate-{resource.Name}-api-access");
        }
    }
    [Fact]
    public void ValidateApiAccess_RejectsTwoApiRegistrationsReferencedByTheSameResource()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var orders = builder.AddEntraIdApplication("orders-entra").WithScope("orders.read", "Read orders.");
        var items = builder.AddEntraIdApplication("items-entra").WithScope("items.read", "Read items.");
 
        // One project validates tokens for both registrations.
        builder.AddResource(new ProjectResource("gateway"))
            .WithReference(orders, connectionName: "Orders")
            .WithReference(items, connectionName: "Items");
 
        var web = builder.AddEntraIdApplication("web-entra")
            .WithApiPermissions(orders, "orders.read")
            .WithApiPermissions(items, "items.read");
 
        // Nothing references the client yet, so there are no entries to collide.
        web.Resource.ValidateApiAccess();
 
        builder.AddResource(new ProjectResource("web")).WithReference(web);
 
        var exception = Assert.Throws<InvalidOperationException>(web.Resource.ValidateApiAccess);
        Assert.Equal(
            "'web-entra' declares permissions to the registrations 'orders-entra' and 'items-entra', and both would be configured as the " +
            "DownstreamApis entry 'gateway', the name of a resource that references both. An entry holds the scopes of one API, so the consumer " +
            "couldn't tell them apart, and one token request can't cover both. Reference 'orders-entra' and 'items-entra' from different resources.",
            exception.Message);
    }
 
    [Fact]
    public async Task ValidateApiAccess_ReferencingTheApisFromDifferentResourcesIsAllowed()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var orders = builder.AddExistingEntraApplication("orders-entra", TenantId, ClientId)
            .WithApplicationIdUri("api://orders").WithScope("orders.read", "Read orders.", s => s.Id = Guid.NewGuid());
        var items = builder.AddExistingEntraApplication("items-entra", TenantId, ClientId)
            .WithApplicationIdUri("api://items").WithScope("items.read", "Read items.", s => s.Id = Guid.NewGuid());
        builder.AddResource(new ProjectResource("orders-svc")).WithReference(orders);
        builder.AddResource(new ProjectResource("items-svc")).WithReference(items);
        var web = builder.AddExistingEntraApplication("web-entra", TenantId, ClientId)
            .WithApiPermissions(orders, "orders.read")
            .WithApiPermissions(items, "items.read");
        var project = builder.AddResource(new ProjectResource("web")).WithReference(web);
 
        web.Resource.ValidateApiAccess();
        var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(project.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        Assert.Equal("api://orders/orders.read", env["DownstreamApis__orders-svc__Scopes__0"]);
        Assert.Equal("api://items/items.read", env["DownstreamApis__items-svc__Scopes__0"]);
    }
 
    [Theory]
    [InlineData("not a uri")]
    [InlineData("relative/path")]
    [InlineData("api://contoso?x=1")]
    public void EntraIdApi_FromApplicationId_RejectsInvalidResourceUris(string uri)
    {
        Assert.Throws<ArgumentException>(() => EntraIdApi.FromApplicationId("Contoso API", "99998888-aaaa-7777-bbbb-6666cccc5555", uri));
    }
 
    [Fact]
    public async Task WithReference_FirstPartyApiScopesUseTheConfiguredResourceUriNotTheApplicationId()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var contoso = EntraIdApi.FromApplicationId("Contoso API", "99998888-aaaa-7777-bbbb-6666cccc5555", "https://contoso.onmicrosoft.com/orders");
        var web = builder.AddExistingEntraApplication("entra-web", TenantId, ClientId)
            .WithApiPermissions(contoso, EntraIdApiPermission.Delegated("Orders.Read", Guid.NewGuid()), EntraIdApiPermission.Application("Orders.Sync", Guid.NewGuid()));
        var project = builder.AddResource(new ProjectResource("web")).WithReference(web);
 
        var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(project.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        Assert.Equal("https://contoso.onmicrosoft.com/orders/Orders.Read", env["DownstreamApis__ContosoAPI__Scopes__0"]);
        Assert.Equal("https://contoso.onmicrosoft.com/orders/.default", env["DownstreamApis__ContosoAPIAppOnly__Scopes__0"]);
        Assert.DoesNotContain(env.Values, v => v.Contains("99998888", StringComparison.Ordinal));
    }
    [Fact]
    public async Task WithReference_FlowsOnlyEnabledExposedScopesToTheApi()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var api = builder.AddExistingEntraApplication("entra-api", TenantId, ClientId)
            .WithScope("access_as_user", ScopeDescription, scope => scope.Id = Guid.NewGuid())
            .WithScope("retired", "Retired.", scope => { scope.Id = Guid.NewGuid(); scope.IsEnabled = false; })
            .WithScope("other", "Another scope.", scope => scope.Id = Guid.NewGuid());
        var project = builder.AddResource(new ProjectResource("api")).WithReference(api);
        var container = builder.AddContainer("worker", "myimage").WithReference(api);
 
        var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(project.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
        var containerEnv = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(container.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        Assert.Equal("access_as_user other", env["AzureAd__Scopes"]);
        Assert.Equal("access_as_user other", containerEnv["ENTRA_API_SCOPES"]);
        Assert.DoesNotContain(env.Keys, k => k.StartsWith("DownstreamApis__", StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task WithReference_WithoutDeclarationsOrScopesAddsNoApiConfiguration()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var web = builder.AddExistingEntraApplication("entra-web", TenantId, ClientId);
        var project = builder.AddResource(new ProjectResource("web")).WithReference(web);
 
        var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(project.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        Assert.Equal(["AzureAd__ClientId", "AzureAd__Instance", "AzureAd__TenantId"], env.Keys.Order());
    }
 
    [Fact]
    public async Task WithReference_RejectsApisThatShareADownstreamName()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var first = EntraIdApi.FromApplicationId("My API", "99998888-aaaa-7777-bbbb-6666cccc5555", "api://my-api");
        var second = EntraIdApi.FromApplicationId("MyAPI", "11110000-aaaa-7777-bbbb-6666cccc5555", "api://my-api-2");
        var permission = EntraIdApiPermission.Delegated("Read", Guid.NewGuid());
        var web = builder.AddExistingEntraApplication("entra-web", TenantId, ClientId)
            .WithApiPermissions(first, permission)
            .WithApiPermissions(second, permission);
        var project = builder.AddResource(new ProjectResource("web")).WithReference(web);
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
            await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(project.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance));
        Assert.Contains("EntraIdApi.FromApplicationId", exception.Message);
    }
 
    [Fact]
    public async Task WithReference_FirstPartyApisWithDifferentDisplayNamesGetSeparateEntries()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var first = EntraIdApi.FromApplicationId("My API", "99998888-aaaa-7777-bbbb-6666cccc5555", "api://my-api");
        var second = EntraIdApi.FromApplicationId("Other API", "11110000-aaaa-7777-bbbb-6666cccc5555", "https://contoso.onmicrosoft.com/other/");
        var permission = EntraIdApiPermission.Delegated("Read", Guid.NewGuid());
        var web = builder.AddExistingEntraApplication("entra-web", TenantId, ClientId)
            .WithApiPermissions(first, permission)
            .WithApiPermissions(second, permission, EntraIdApiPermission.Application("Read.All", Guid.NewGuid()));
        var project = builder.AddResource(new ProjectResource("web")).WithReference(web);
 
        var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(project.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        Assert.Equal("api://my-api/Read", env["DownstreamApis__MyAPI__Scopes__0"]);
        // The trailing slash is part of the resource identifier. It separates a permission from the identifier, and ".default" follows
        // another slash, as in "https://management.azure.com//.default".
        Assert.Equal("https://contoso.onmicrosoft.com/other/Read", env["DownstreamApis__OtherAPI__Scopes__0"]);
        Assert.Equal("https://contoso.onmicrosoft.com/other//.default", env["DownstreamApis__OtherAPIAppOnly__Scopes__0"]);
    }
    [Fact]
    public async Task WithReference_NamesDownstreamApisAfterTheResourcesThatReferenceTheApi()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var apiEntra = builder.AddExistingEntraApplication("apiservice-entra", TenantId, ClientId)
            .WithApplicationIdUri(ApiUri)
            .WithScope("access_as_user", ScopeDescription, scope => scope.Id = Guid.NewGuid());
        var webEntra = builder.AddExistingEntraApplication("webfrontend-entra", TenantId, ClientId)
            .WithApiPermissions(apiEntra, "access_as_user");
 
        // The consumers are read when the environment is resolved, so the order of the calls doesn't matter.
        var webFrontend = builder.AddResource(new ProjectResource("webfrontend")).WithReference(webEntra);
        builder.AddResource(new ProjectResource("apiservice")).WithReference(apiEntra);
        builder.AddResource(new ProjectResource("apiservice2")).WithReference(apiEntra);
 
        var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(webFrontend.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        Assert.Equal(
            ["DownstreamApis__apiservice2__Scopes__0", "DownstreamApis__apiservice__Scopes__0"],
            env.Keys.Where(k => k.StartsWith("DownstreamApis__", StringComparison.Ordinal)).Order(StringComparer.Ordinal));
    }
 
    [Fact]
    public async Task WithReference_FallsBackToTheApiRegistrationNameWhenNothingReferencesIt()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var apiEntra = builder.AddExistingEntraApplication("apiservice-entra", TenantId, ClientId)
            .WithApplicationIdUri(ApiUri)
            .WithScope("access_as_user", ScopeDescription, scope => scope.Id = Guid.NewGuid());
        var webEntra = builder.AddExistingEntraApplication("webfrontend-entra", TenantId, ClientId)
            .WithApiPermissions(apiEntra, "access_as_user");
        var webFrontend = builder.AddResource(new ProjectResource("webfrontend")).WithReference(webEntra);
 
        var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(webFrontend.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        Assert.Equal($"{ApiUri}/access_as_user", env["DownstreamApis__apiservice_entra__Scopes__0"]);
    }
 
    [Theory]
    [InlineData("MyDownstreamApis", "MyDownstreamApis__apiservice__Scopes__0")]
    [InlineData("Section:MyApis", "Section__MyApis__apiservice__Scopes__0")]
    public async Task WithReference_UsesTheDownstreamApisPrefix(string prefix, string expected)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var apiEntra = builder.AddExistingEntraApplication("apiservice-entra", TenantId, ClientId)
            .WithApplicationIdUri(ApiUri)
            .WithScope("access_as_user", ScopeDescription, scope => scope.Id = Guid.NewGuid());
        var webEntra = builder.AddExistingEntraApplication("webfrontend-entra", TenantId, ClientId)
            .WithApiPermissions(apiEntra, "access_as_user");
        builder.AddResource(new ProjectResource("apiservice")).WithReference(apiEntra);
        var webFrontend = builder.AddResource(new ProjectResource("webfrontend"))
            .WithReference(webEntra, connectionName: null, downstreamApisPrefix: prefix);
 
        var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(webFrontend.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        Assert.Equal($"{ApiUri}/access_as_user", env[expected]);
        Assert.Single(env.Keys, k => k.Contains("Apis", StringComparison.Ordinal));
        Assert.Equal("AzureAd__ClientId", Assert.Single(env.Keys, k => k.EndsWith("ClientId", StringComparison.Ordinal)));
    }
 
    [Fact]
    public async Task WithReference_UsesTheDownstreamApisPrefixForOtherLanguages()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var apiEntra = builder.AddExistingEntraApplication("apiservice-entra", TenantId, ClientId)
            .WithApplicationIdUri(ApiUri)
            .WithScope("access_as_user", ScopeDescription, scope => scope.Id = Guid.NewGuid());
        var webEntra = builder.AddExistingEntraApplication("webfrontend-entra", TenantId, ClientId)
            .WithApiPermissions(apiEntra, "access_as_user");
        builder.AddContainer("api-container", "myimage").WithReference(apiEntra);
        var worker = builder.AddContainer("worker", "myimage").WithReference(webEntra, "ENTRA", "MyApis");
        var defaultWorker = builder.AddContainer("default-worker", "myimage").WithReference(webEntra, "ENTRA");
 
        var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(worker.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
        var defaultEnv = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(defaultWorker.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        Assert.Equal($"{ApiUri}/access_as_user", env["ENTRA_MY_APIS_API_CONTAINER_SCOPES_0"]);
        Assert.Equal($"{ApiUri}/access_as_user", defaultEnv["ENTRA_DOWNSTREAM_APIS_API_CONTAINER_SCOPES_0"]);
    }
 
    [Fact]
    public void WithReference_RejectsAnEmptyDownstreamApisPrefix()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var webEntra = builder.AddExistingEntraApplication("webfrontend-entra", TenantId, ClientId);
        var project = builder.AddResource(new ProjectResource("webfrontend"));
 
        Assert.Throws<ArgumentException>(() => project.WithReference(webEntra, connectionName: null, downstreamApisPrefix: ""));
        Assert.Throws<ArgumentException>(() => project.WithReference(webEntra, connectionName: null, downstreamApisPrefix: " "));
    }
 
    [Fact]
    public async Task BeforeStart_FailsWhenTwoApiRegistrationsAreReferencedByTheSameResource()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var orders = builder.AddExistingEntraApplication("orders-entra", TenantId, ClientId)
            .WithApplicationIdUri("api://orders").WithScope("orders.read", "Read orders.", s => s.Id = Guid.NewGuid());
        var items = builder.AddExistingEntraApplication("items-entra", TenantId, ClientId)
            .WithApplicationIdUri("api://items").WithScope("items.read", "Read items.", s => s.Id = Guid.NewGuid());
        builder.AddResource(new ProjectResource("gateway")).WithReference(orders, "Orders").WithReference(items, "Items");
        var web = builder.AddExistingEntraApplication("web-entra", TenantId, ClientId)
            .WithApiPermissions(orders, "orders.read")
            .WithApiPermissions(items, "items.read");
        builder.AddResource(new ProjectResource("web")).WithReference(web);
 
        using var app = builder.Build();
 
        // The model is rejected before any resource starts, not when a consumer later resolves its environment.
        var exception = await Assert.ThrowsAnyAsync<Exception>(() => ExecuteBeforeStartHooksAsync(app, default));
        Assert.Contains("validate-web-entra-api-access", exception.ToString());
        Assert.Contains("DownstreamApis entry 'gateway'", exception.ToString());
    }
 
    [Fact]
    public async Task BeforeStart_FailsWhenAnExistingApiHasNoApplicationIdUri()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var api = builder.AddExistingEntraApplication("items-entra", TenantId, ClientId)
            .WithScope("items.read", "Read items.", s => s.Id = Guid.NewGuid());
        var web = builder.AddExistingEntraApplication("web-entra", TenantId, ClientId)
            .WithApiPermissions(api, "items.read");
        builder.AddResource(new ProjectResource("web")).WithReference(web);
 
        using var app = builder.Build();
 
        var exception = await Assert.ThrowsAnyAsync<Exception>(() => ExecuteBeforeStartHooksAsync(app, default));
        Assert.Contains("WithApplicationIdUri on 'items-entra'", exception.ToString());
    }
 
    [Fact]
    public async Task BeforeStart_SucceedsWhenTheDeclaredApisAreValid()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var api = builder.AddExistingEntraApplication("items-entra", TenantId, ClientId)
            .WithApplicationIdUri("api://items").WithScope("items.read", "Read items.", s => s.Id = Guid.NewGuid());
        builder.AddResource(new ProjectResource("items-svc")).WithReference(api);
        var web = builder.AddExistingEntraApplication("web-entra", TenantId, ClientId)
            .WithApiPermissions(api, "items.read")
            .WithApiPermissions(EntraIdApi.MicrosoftGraph, EntraIdApiPermission.Delegated("User.Read"));
        builder.AddResource(new ProjectResource("web")).WithReference(web);
 
        using var app = builder.Build();
 
        await ExecuteBeforeStartHooksAsync(app, default);
    }
 
    [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")]
    private static extern Task ExecuteBeforeStartHooksAsync(DistributedApplication app, CancellationToken cancellationToken);
 
    [Fact]
    public async Task Symbols_ApisWhoseNamesDifferOnlyByHyphenOrCaseKeepSeparateParameters()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var hyphenated = builder.AddEntraIdApplication("orders-api").WithScope("read", "Read");
        var camel = builder.AddEntraIdApplication("ordersApi").WithScope("read", "Read");
        var web = builder.AddEntraIdApplication("web")
            .WithApiPermissions(hyphenated, "read")
            .WithApiPermissions(camel, "read")
            .WithAdminConsent(hyphenated, "read")
            .WithAdminConsent(camel, "read");
 
        var bicep = web.Resource.GetBicepTemplateString();
 
        // Both used to be named ordersApiClientId, which bound the parameter twice and wired both requirements to the last API.
        Assert.Equal("orders-api", ((BicepOutputReference)web.Resource.Parameters["orders_apiClientId"]!).Resource.Name);
        Assert.Equal("ordersApi", ((BicepOutputReference)web.Resource.Parameters["ordersApiClientId"]!).Resource.Name);
        Assert.Equal("orders-api", ((BicepOutputReference)web.Resource.Parameters["orders_apiServicePrincipalId"]!).Resource.Name);
        Assert.Equal("ordersApi", ((BicepOutputReference)web.Resource.Parameters["ordersApiServicePrincipalId"]!).Resource.Name);
        AssertSymbolsAreUniqueValidIdentifiers(bicep);
        Assert.Equal(bicep, web.Resource.GetBicepTemplateString());
        await Verify(bicep, "bicep");
    }
 
    [Fact]
    public async Task Symbols_CustomApisWithTheSameDisplayNameKeepSeparateSymbols()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var one = EntraIdApi.FromApplicationId("Contoso API", "99998888-aaaa-7777-bbbb-6666cccc5555", "api://one");
        var two = EntraIdApi.FromApplicationId("Contoso API", "11110000-aaaa-7777-bbbb-6666cccc5555", "api://two");
        var read = EntraIdApiPermission.Delegated("Read", Guid.Parse("33334444-aaaa-5555-bbbb-6666cccc7777"));
        var web = builder.AddEntraIdApplication("web")
            .WithApiPermissions(one, read)
            .WithApiPermissions(two, read)
            .WithAdminConsent(one, read)
            .WithAdminConsent(two, read);
 
        var bicep = web.Resource.GetBicepTemplateString();
 
        // Two grants and two service principal lookups with the same symbol would not compile.
        AssertSymbolsAreUniqueValidIdentifiers(bicep);
        Assert.Contains("contosoAPIDelegatedGrant ", bicep);
        Assert.Contains("contosoAPIDelegatedGrant_2 ", bicep);
        await Verify(bicep, "bicep");
    }
 
    [Fact]
    public void Symbols_RoleAssignmentsWhoseNamesWouldRunTogetherStayDistinct()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var a = EntraIdApi.FromApplicationId("a", "99998888-aaaa-7777-bbbb-6666cccc5555", "api://a");
        var aB = EntraIdApi.FromApplicationId("aB", "11110000-aaaa-7777-bbbb-6666cccc5555", "api://ab");
        var bc = EntraIdApiPermission.Application("BC", Guid.Parse("33334444-aaaa-5555-bbbb-6666cccc7777"));
        var c = EntraIdApiPermission.Application("C", Guid.Parse("44445555-aaaa-5555-bbbb-6666cccc7777"));
        var web = builder.AddEntraIdApplication("web")
            .WithApiPermissions(a, bc)
            .WithApiPermissions(aB, c)
            .WithAdminConsent(a, bc)
            .WithAdminConsent(aB, c);
 
        var bicep = web.Resource.GetBicepTemplateString();
 
        // "a" + "BC" and "aB" + "C" both read aBCRoleAssignment.
        AssertSymbolsAreUniqueValidIdentifiers(bicep);
        Assert.Contains("aBCRoleAssignment ", bicep);
        Assert.Contains("aBCRoleAssignment_2 ", bicep);
    }
 
    [Fact]
    public void Symbols_AreValidWhenADisplayNameDoesNotStartWithALetter()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var digits = EntraIdApi.FromApplicationId("2FA API", "99998888-aaaa-7777-bbbb-6666cccc5555", "api://2fa");
        var punctuation = EntraIdApi.FromApplicationId("---", "11110000-aaaa-7777-bbbb-6666cccc5555", "api://dashes");
        var read = EntraIdApiPermission.Delegated("Read", Guid.Parse("33334444-aaaa-5555-bbbb-6666cccc7777"));
        var web = builder.AddEntraIdApplication("web")
            .WithApiPermissions(digits, read)
            .WithApiPermissions(punctuation, read)
            .WithAdminConsent(digits, read)
            .WithAdminConsent(punctuation, read);
 
        var bicep = web.Resource.GetBicepTemplateString();
 
        AssertSymbolsAreUniqueValidIdentifiers(bicep);
        Assert.Contains("api2FAAPIDelegatedGrant ", bicep);
    }
 
    [Fact]
    public void Symbols_DoNotCollideWithParametersFromTheAppHost()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var api = builder.AddEntraIdApplication("orders-api").WithScope("read", "Read");
 
        // A redirect URI parameter whose symbol is exactly the one that the API's client ID would take: a resource name can't contain
        // an underscore, so "orders-apiClientId" is the spelling that normalizes to "orders_apiClientId".
        var web = builder.AddEntraIdApplication("web")
            .WithRedirectUris(builder.AddParameter("orders-apiClientId"))
            .WithApiPermissions(api, "read");
 
        var bicep = web.Resource.GetBicepTemplateString();
 
        AssertSymbolsAreUniqueValidIdentifiers(bicep);
        Assert.IsType<ParameterResource>(web.Resource.Parameters["orders_apiClientId"]);
        Assert.Equal("orders-api", ((BicepOutputReference)web.Resource.Parameters["orders_apiClientId_2"]!).Resource.Name);
    }
 
    [Fact]
    public void Symbols_TheSecondDeclarationDoesNotCollideWithAParameterFromTheAppHost()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        // The second declaration of an API registration is the resource "applicationIdentity" unless the template already has that name.
        var api = builder.AddEntraIdApplication("api")
            .WithRedirectUris(builder.AddParameter("applicationIdentity"))
            .WithScope("read", "Read");
 
        var bicep = api.Resource.GetBicepTemplateString();
 
        AssertSymbolsAreUniqueValidIdentifiers(bicep);
        Assert.Contains("param applicationIdentity string", bicep);
        Assert.Contains("resource applicationIdentity_2 'Microsoft.Graph/applications@v1.0'", bicep);
    }
 
    [Fact]
    public void Symbols_SameRegistrationAsApiAndPreAuthorizedClientSharesOneParameter()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var shared = builder.AddExistingEntraApplication("shared", builder.AddParameter("shared-tenant-id"), builder.AddParameter("shared-client-id"))
            .WithApplicationIdUri("api://shared")
            .WithScope("read", "Read", scope => scope.Id = Guid.NewGuid());
        var registration = builder.AddEntraIdApplication("registration")
            .WithScope("read", "Read")
            .WithPreAuthorizedApplication(shared, "read")
            .WithApiPermissions(shared, "read");
 
        var bicep = registration.Resource.GetBicepTemplateString();
 
        // The registration needs the same client ID as an API it calls and as a client it pre-authorizes: one parameter, one binding.
        Assert.Single(bicep.Split('\n'), line => line.StartsWith("param shared", StringComparison.Ordinal));
        Assert.Single(registration.Resource.Parameters, p => p.Key.StartsWith("shared", StringComparison.Ordinal));
        AssertSymbolsAreUniqueValidIdentifiers(bicep);
    }
    private static void AssertSymbolsAreUniqueValidIdentifiers(string bicep)
    {
        // Parameters and resources share one case-insensitive namespace in a template.
        var symbols = System.Text.RegularExpressions.Regex.Matches(bicep, @"^(?:param|resource) (\S+)", System.Text.RegularExpressions.RegexOptions.Multiline)
            .Select(m => m.Groups[1].Value)
            .ToArray();
 
        Assert.NotEmpty(symbols);
        Assert.Equal(symbols.Length, symbols.Distinct(StringComparer.OrdinalIgnoreCase).Count());
        Assert.All(symbols, symbol => Assert.Matches("^[A-Za-z_][A-Za-z0-9_]*$", symbol));
    }
 
    private static (IResourceBuilder<EntraIdApplicationResource> First, IResourceBuilder<EntraIdApplicationResource> Second) AddTwoClientsOfOneApi(IDistributedApplicationBuilder builder)
    {
        var api = builder.AddExistingEntraApplication("apiservice-entra", TenantId, ClientId)
            .WithApplicationIdUri(ApiUri)
            .WithScope("read", "Read", scope => scope.Id = Guid.NewGuid())
            .WithScope("write", "Write", scope => scope.Id = Guid.NewGuid());
        builder.AddResource(new ProjectResource("apiservice")).WithReference(api);
 
        var first = builder.AddExistingEntraApplication("web-a", TenantId, ClientId).WithApiPermissions(api, "read", "write");
        var second = builder.AddExistingEntraApplication("web-b", TenantId, ClientId).WithApiPermissions(api, "read");
        return (first, second);
    }
 
    [Fact]
    public async Task WithReference_RejectsTwoRegistrationsThatWriteTheSameDownstreamApisKeys()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var (first, second) = AddTwoClientsOfOneApi(builder);
 
        // For a .NET program the DownstreamApis section doesn't depend on the connection name, so both would write
        // DownstreamApis__apiservice__Scopes__0, and web-a's Scopes__1 would survive in web-b's configuration.
        var frontend = builder.AddResource(new ProjectResource("frontend"))
            .WithReference(first, "AzureAdA")
            .WithReference(second, "AzureAdB");
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
            await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(frontend.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance));
 
        Assert.Equal(
            "'frontend' references the Entra ID applications 'web-a' and 'web-b', and both declare permissions to an API that is configured as " +
            "the DownstreamApis entry 'apiservice' under 'DownstreamApis', so their scopes would be mixed. Pass a different downstreamApisPrefix to WithReference for one of them.",
            exception.Message);
        Assert.Equal(exception.Message, Assert.Throws<InvalidOperationException>(first.Resource.ValidateApiAccess).Message);
    }
 
    [Fact]
    public async Task WithReference_RejectsReferencesWhoseEncodedPrefixesAreTheSame()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var (first, second) = AddTwoClientsOfOneApi(builder);
 
        // A non-.NET resource receives upper snake case names, so the prefixes "A"/"B-C" and "A_B"/"C" differ as written but
        // both produce A_B_C_APISERVICE_SCOPES_0.
        var worker = builder.AddContainer("worker", "myimage")
            .WithReference(first, "A", "B-C")
            .WithReference(second, "A_B", "C");
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
            await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(worker.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance));
 
        Assert.Contains("downstreamApisPrefix", exception.Message);
    }
 
    private static IResourceBuilder<EntraIdApplicationResource> AddClientOfTwoApisNamedAfterTheirConsumers(IDistributedApplicationBuilder builder)
    {
        var firstApi = builder.AddExistingEntraApplication("first-api", TenantId, ClientId)
            .WithApplicationIdUri("api://first")
            .WithScope("read", "Read", scope => scope.Id = Guid.NewGuid());
        var secondApi = builder.AddExistingEntraApplication("second-api", TenantId, ClientId)
            .WithApplicationIdUri("api://second")
            .WithScope("read", "Read", scope => scope.Id = Guid.NewGuid());
 
        // The entries are named after the resources that reference the APIs. As configuration keys they are different, but as
        // environment variables of a non-.NET resource they are both ORDERS_API.
        builder.AddResource(new ProjectResource("orders-api")).WithReference(firstApi);
        builder.AddResource(new ProjectResource("ordersApi")).WithReference(secondApi);
        return builder.AddExistingEntraApplication("web", TenantId, ClientId)
            .WithApiPermissions(firstApi, "read")
            .WithApiPermissions(secondApi, "read");
    }
 
    [Fact]
    public async Task WithReference_KeepsEntriesApartWhoseNamesOnlyDifferInCaseOrPunctuationForDotnetResources()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var web = AddClientOfTwoApisNamedAfterTheirConsumers(builder);
        var frontend = builder.AddResource(new ProjectResource("frontend")).WithReference(web);
 
        var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(frontend.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        Assert.Equal("api://first/read", env["DownstreamApis__orders-api__Scopes__0"]);
        Assert.Equal("api://second/read", env["DownstreamApis__ordersApi__Scopes__0"]);
    }
 
    [Fact]
    public async Task WithReference_RejectsEntriesWhoseEncodedNamesAreTheSameForNonDotnetResources()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var web = AddClientOfTwoApisNamedAfterTheirConsumers(builder);
        var worker = builder.AddContainer("worker", "myimage").WithReference(web);
 
        // The environment would otherwise be built from duplicate keys.
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
            await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(worker.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance));
 
        Assert.Contains("'orders-api'", exception.Message);
        Assert.Contains("'ordersApi'", exception.Message);
    }
 
    [Fact]
    public async Task WithReference_ADifferentDownstreamApisPrefixKeepsTheTwoRegistrationsApart()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var (first, second) = AddTwoClientsOfOneApi(builder);
        var frontend = builder.AddResource(new ProjectResource("frontend"))
            .WithReference(first, "AzureAdA")
            .WithReference(second, "AzureAdB", downstreamApisPrefix: "DownstreamApisB");
 
        first.Resource.ValidateApiAccess();
        var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(frontend.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        Assert.Equal($"{ApiUri}/read", env["DownstreamApis__apiservice__Scopes__0"]);
        Assert.Equal($"{ApiUri}/write", env["DownstreamApis__apiservice__Scopes__1"]);
        Assert.Equal($"{ApiUri}/read", env["DownstreamApisB__apiservice__Scopes__0"]);
        Assert.DoesNotContain("DownstreamApisB__apiservice__Scopes__1", env.Keys);
    }
 
    [Fact]
    public async Task WithReference_RegistrationsOnANonDotnetResourceUseTheirOwnKeys()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var (first, second) = AddTwoClientsOfOneApi(builder);
 
        // Other languages prefix the entries with the connection name, so each reference has its own keys.
        var worker = builder.AddContainer("worker", "myimage").WithReference(first, "A").WithReference(second, "B");
 
        var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(worker.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        Assert.Equal($"{ApiUri}/write", env["A_DOWNSTREAM_APIS_APISERVICE_SCOPES_1"]);
        Assert.DoesNotContain("B_DOWNSTREAM_APIS_APISERVICE_SCOPES_1", env.Keys);
    }
 
    [Fact]
    public async Task WithReference_RegistrationsOnANonDotnetResourceWithTheSameConnectionNameClash()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var (first, second) = AddTwoClientsOfOneApi(builder);
 
        // The same connection name for both makes the keys the same again.
        var worker = builder.AddContainer("worker", "myimage").WithReference(first, "A").WithReference(second, "A");
 
        await Assert.ThrowsAsync<InvalidOperationException>(async () =>
            await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(worker.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance));
    }
    [Fact]
    public async Task WithReference_ReferencingOneRegistrationTwiceIsNotAClash()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var (first, _) = AddTwoClientsOfOneApi(builder);
        var frontend = builder.AddResource(new ProjectResource("frontend"))
            .WithReference(first)
            .WithReference(first, "AzureAdAgain");
 
        first.Resource.ValidateApiAccess();
        var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(frontend.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
 
        Assert.Equal($"{ApiUri}/write", env["DownstreamApis__apiservice__Scopes__1"]);
    }
 
    [Fact]
    public async Task BeforeStart_FailsWhenAResourceReferencesTwoRegistrationsThatShareDownstreamApisKeys()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var (first, second) = AddTwoClientsOfOneApi(builder);
        builder.AddResource(new ProjectResource("frontend")).WithReference(first, "AzureAdA").WithReference(second, "AzureAdB");
 
        using var app = builder.Build();
 
        var exception = await Assert.ThrowsAnyAsync<Exception>(() => ExecuteBeforeStartHooksAsync(app, default));
        Assert.Contains("downstreamApisPrefix", exception.ToString());
    }
 
    [Fact]
    public void ScopeId_ChangedAfterDeclaringThePermissionIsUsedByBothTheCallerAndTheApi()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var api = builder.AddEntraIdApplication("entra-api")
            .WithScope("access_as_user", ScopeDescription)
            .WithScope("other", "Another scope.");
        var web = builder.AddEntraIdApplication("entra-web")
            .WithApiPermissions(api, "access_as_user")
            .WithAdminConsent(api, "access_as_user");
        var derivedId = api.Resource.GetScopeId(api.Resource.Scopes[0]).ToString("D");
 
        // Adopting an existing scope by its ID is a later call than the declaration in an AppHost that configures in several places.
        var adoptedId = Guid.Parse("11112222-aaaa-3333-bbbb-4444cccc5555");
        api.Resource.Scopes[0].Id = adoptedId;
 
        var apiBicep = api.Resource.GetBicepTemplateString();
        var webBicep = web.Resource.GetBicepTemplateString();
 
        // The caller used to keep the ID that was derived when it declared the permission, so it would request a scope that doesn't exist.
        Assert.Contains("id: '11112222-aaaa-3333-bbbb-4444cccc5555'", apiBicep);
        Assert.Contains("id: '11112222-aaaa-3333-bbbb-4444cccc5555'", webBicep);
        Assert.DoesNotContain(derivedId, apiBicep);
        Assert.DoesNotContain(derivedId, webBicep);
    }
 
    [Fact]
    public void ScopeId_ChangedAfterDeclaringThePermissionIsUsedForAnExistingApi()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var firstId = Guid.Parse("11112222-aaaa-3333-bbbb-4444cccc5555");
        var secondId = Guid.Parse("22223333-aaaa-4444-bbbb-5555cccc6666");
        var existingApi = builder.AddExistingEntraApplication("existing-api", TenantId, ClientId)
            .WithApplicationIdUri("api://contoso-orders")
            .WithScope("orders.read", "Read orders.", scope => scope.Id = firstId);
        var web = builder.AddEntraIdApplication("entra-web").WithApiPermissions(existingApi, "orders.read");
 
        existingApi.Resource.Scopes[0].Id = secondId;
 
        var webBicep = web.Resource.GetBicepTemplateString();
        Assert.Contains($"id: '{secondId:D}'", webBicep);
        Assert.DoesNotContain(firstId.ToString("D"), webBicep);
    }
 
    [Fact]
    public void ScopeId_AnExistingScopeThatLosesItsIdIsReportedWhenTheTemplateIsGenerated()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var existingApi = builder.AddExistingEntraApplication("existing-api", TenantId, ClientId)
            .WithApplicationIdUri("api://contoso-orders")
            .WithScope("orders.read", "Read orders.", scope => scope.Id = Guid.NewGuid());
        var web = builder.AddEntraIdApplication("entra-web").WithApiPermissions(existingApi, "orders.read");
 
        existingApi.Resource.Scopes[0].Id = null;
 
        var exception = Assert.Throws<InvalidOperationException>(web.Resource.GetBicepTemplateString);
        Assert.Contains("EntraIdScope.Id", exception.Message);
    }
 
    [Fact]
    public void ScopeId_DeclaringTheSameScopeTwiceStaysOnePermission()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var api = builder.AddEntraIdApplication("entra-api").WithScope("access_as_user", ScopeDescription);
        var web = builder.AddEntraIdApplication("entra-web")
            .WithApiPermissions(api, "access_as_user")
            .WithApiPermissions(api, "access_as_user")
            .WithAdminConsent(api, "access_as_user")
            .WithAdminConsent(api, "access_as_user");
 
        var access = Assert.Single(web.Resource.ApiAccess);
        Assert.Single(access.Declared);
        Assert.Single(access.Consented);
    }
}