File: AzureEntraIdDeploymentTests.cs
Web Access
Project: src\tests\Aspire.Deployment.EndToEnd.Tests\Aspire.Deployment.EndToEnd.Tests.csproj (Aspire.Deployment.EndToEnd.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using Aspire.Deployment.EndToEnd.Tests.Helpers;
using Azure.Core;
using Hex1b.Automation;
using Xunit;
 
namespace Aspire.Deployment.EndToEnd.Tests;
 
public sealed class AzureEntraIdDeploymentTests(ITestOutputHelper output)
{
    private const string RedirectUri = "https://example.invalid/signin-oidc";
    private const string UpdatedRedirectUri = "https://example.invalid/updated-signin-oidc";
 
    [Fact]
    public async Task DeployEntraRegistrationAndRedeployWithStableIds()
    {
        Assert.SkipUnless(OperatingSystem.IsLinux(), "Deployment terminal automation requires Linux.");
 
        var subscriptionId = DotnetProjectDeploymentHelpers.GetSubscriptionId();
        Assert.True(Guid.TryParse(subscriptionId, out _), "The deployment subscription must be a GUID.");
        Assert.True(Guid.TryParse(AzureAuthenticationHelpers.GetTenantId(), out var tenantId),
            "Set AZURE_TENANT_ID to the deployment tenant ID.");
        var strategy = DeploymentE2ETestHelpers.GetCurrentBuildCliInstallStrategy();
        Assert.SkipUnless(
            strategy.Mode is CliInstallMode.Preinstalled or CliInstallMode.LocalHive or CliInstallMode.LocalArchive or CliInstallMode.PullRequest,
            "This scenario requires current-build artifacts. Set ASPIRE_E2E_ARCHIVE for a local run.");
 
        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
        timeout.CancelAfter(TimeSpan.FromMinutes(60));
        var cancellationToken = timeout.Token;
        using var workspace = TemporaryWorkspace.Create(output);
        using var client = new HttpClient();
        var credential = AzureAuthenticationHelpers.GetAzureCredential();
        var suffix = Guid.NewGuid().ToString("N");
        var registrationName = $"identity-{suffix}";
        var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName($"entra-{suffix[..8]}");
        var resourceGroupUrl = $"https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}?api-version=2021-04-01";
        // Address the same immutable alternate key as Graph Bicep upserts, not the non-unique display name.
        // https://learn.microsoft.com/graph/api/application-upsert
        var applicationUrl = $"https://graph.microsoft.com/v1.0/applications(uniqueName='{registrationName}')";
        var started = DateTime.UtcNow;
        var deploymentAttempted = false;
        Exception? deploymentFailure = null;
        var deploymentUrls = new Dictionary<string, string>();
 
        // Only take cleanup ownership after proving these test-specific names are unused.
        using (var existingGroup = await ReadResourceAsync(client, credential, resourceGroupUrl, cancellationToken))
        using (var existingApplication = await ReadResourceAsync(client, credential, applicationUrl, cancellationToken))
        {
            Assert.Null(existingGroup);
            Assert.Null(existingApplication);
        }
 
        try
        {
            using var terminalCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
            using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(width: 320);
            var pendingRun = terminal.RunAsync(terminalCancellation.Token);
            var counter = new SequenceCounter();
            var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
            try
            {
                await auto.PrepareEnvironmentAsync(workspace, counter);
                await auto.InstallAspireCliAsync(strategy, counter, output);
                await auto.AspireNewAsync("EntraDeployment", counter, template: AspireTemplate.EmptyAppHost);
                await auto.RunCommandAsync("cd EntraDeployment", counter);
                await DotnetProjectDeploymentHelpers.AddPackageAsync(auto, counter, "Aspire.Hosting.Azure.AppContainers");
                await DotnetProjectDeploymentHelpers.AddPackageAsync(auto, counter, "Aspire.Hosting.Azure.EntraId");
                await auto.RunCommandAsync("dotnet new web -n Consumer --no-restore", counter, TimeSpan.FromMinutes(2));
                var projectDirectory = Path.Combine(workspace.Path, "EntraDeployment");
                WriteApplication(projectDirectory);
 
                await auto.RunCommandAsync(
                    "unset ASPIRE_PLAYGROUND Azure__Location Azure__ResourceGroup Azure__SubscriptionId Azure__TenantId Azure__TenantResourceSuffix && " +
                    $"export AZURE__LOCATION=westus3 AZURE__RESOURCEGROUP={AspireCliShellCommandHelpers.QuoteBashArg(resourceGroupName)} " +
                    $"AZURE__SUBSCRIPTIONID={AspireCliShellCommandHelpers.QuoteBashArg(subscriptionId)} " +
                    $"AZURE__TENANTID={tenantId:D} AZURE__TENANTRESOURCESUFFIX={suffix}",
                    counter);
 
                deploymentAttempted = true;
                await DeployAsync(auto, counter);
                var firstIds = await VerifyDeploymentAsync(
                    client, credential, subscriptionId, resourceGroupName, registrationName, tenantId, RedirectUri, deploymentUrls, cancellationToken);
 
                // Change actual Graph settings so the second deployment must update the registration,
                // rather than only demonstrating that a cached deployment preserves its IDs.
                DotnetProjectDeploymentHelpers.ReplaceInFile(
                    Path.Combine(projectDirectory, "apphost.cs"), RedirectUri, UpdatedRedirectUri);
                await DeployAsync(auto, counter);
                var secondIds = await VerifyDeploymentAsync(
                    client, credential, subscriptionId, resourceGroupName, registrationName, tenantId, UpdatedRedirectUri, deploymentUrls, cancellationToken);
                Assert.Equal(firstIds, secondIds);
 
                await auto.TypeAsync("exit");
                await auto.EnterAsync();
                await pendingRun.WaitAsync(TimeSpan.FromSeconds(30), cancellationToken);
            }
            finally
            {
                // Stop terminal-driven deployment before cleanup, even after the test budget expires.
                await terminalCancellation.CancelAsync();
                try
                {
                    await pendingRun.WaitAsync(TimeSpan.FromSeconds(30));
                }
                catch (OperationCanceledException) when (terminalCancellation.IsCancellationRequested)
                {
                    // Terminal cancellation is expected on failure or timeout.
                }
            }
        }
        catch (Exception ex)
        {
            deploymentFailure = ex;
            DeploymentReporter.ReportDeploymentFailure(
                nameof(DeployEntraRegistrationAndRedeployWithStableIds), resourceGroupName, ex.Message);
            throw;
        }
        finally
        {
            if (deploymentAttempted)
            {
                // Directory objects are not owned by the resource group. Both cleanup paths must run,
                // including when deployment failed before outputs could be read.
                try
                {
                    await CleanupAsync(client, credential, resourceGroupUrl, applicationUrl);
                    DeploymentReporter.ReportCleanupStatus(resourceGroupName, true, "Resource group and Entra directory objects deleted.");
                }
                catch (Exception ex)
                {
                    DeploymentReporter.ReportCleanupStatus(resourceGroupName, false, ex.Message);
                    var failures = new List<Exception> { ex };
                    if (deploymentFailure is not null)
                    {
                        failures.Insert(0, deploymentFailure);
                    }
 
                    throw new AggregateException("Entra deployment cleanup failed.", failures);
                }
            }
        }
 
        DeploymentReporter.ReportDeploymentSuccess(
            nameof(DeployEntraRegistrationAndRedeployWithStableIds), resourceGroupName, deploymentUrls, DateTime.UtcNow - started);
    }
 
    private static Task DeployAsync(Hex1bTerminalAutomator auto, SequenceCounter counter)
    {
        // Match this command's exit prompt, not a previous deployment's success banner.
        return auto.RunCommandAsync("aspire deploy --clear-cache --non-interactive", counter, TimeSpan.FromMinutes(25));
    }
 
    private static void WriteApplication(string projectDirectory)
    {
        var appHostPath = Path.Combine(projectDirectory, "apphost.cs");
        var directives = File.ReadLines(appHostPath).Where(line => line.StartsWith("#:", StringComparison.Ordinal)).ToArray();
        Assert.NotEmpty(directives);
        File.WriteAllText(appHostPath, $$"""
            {{string.Join(Environment.NewLine, directives)}}
 
            #pragma warning disable ASPIREACAEXPRESS001
 
            var builder = DistributedApplication.CreateBuilder(args);
            builder.AddAzureContainerAppEnvironment("env").AsExpress();
            var identity = builder.AddEntraIdApplication("identity")
                .WithRedirectUris("{{RedirectUri}}");
 
            builder.AddProject("consumer", "Consumer/Consumer.csproj")
                .WithHttpEndpoint(targetPort: 8080)
                .WithExternalHttpEndpoints()
                .WithReference(identity);
 
            builder.Build().Run();
            """);
        File.Delete(Path.Combine(projectDirectory, "Consumer", "Properties", "launchSettings.json"));
        File.WriteAllText(Path.Combine(projectDirectory, "Consumer", "Program.cs"), """
            var builder = WebApplication.CreateBuilder(args);
            var tenantId = builder.Configuration["AzureAd:TenantId"]
                ?? throw new InvalidOperationException("Entra tenant ID is missing.");
            var clientId = builder.Configuration["AzureAd:ClientId"]
                ?? throw new InvalidOperationException("Entra client ID is missing.");
            var app = builder.Build();
            app.MapGet("/", () => $"{tenantId}/{clientId}");
            app.Run();
            """);
    }
 
    private static async Task<(Guid ObjectId, Guid ClientId, Guid ServicePrincipalId)> VerifyDeploymentAsync(
        HttpClient client, TokenCredential credential, string subscriptionId, string resourceGroupName,
        string registrationName, Guid tenantId, string redirectUri, Dictionary<string, string> deploymentUrls,
        CancellationToken cancellationToken)
    {
        var application = await WaitForResourceAsync(client, credential,
            $"https://graph.microsoft.com/v1.0/applications(uniqueName='{registrationName}')",
            resource => resource.GetProperty("web").GetProperty("redirectUris").EnumerateArray()
                .Select(uri => uri.GetString()).SequenceEqual([redirectUri]),
            cancellationToken);
        Assert.Equal(registrationName, application.GetProperty("displayName").GetString());
        Assert.Equal(registrationName, application.GetProperty("uniqueName").GetString());
        Assert.Equal("AzureADMyOrg", application.GetProperty("signInAudience").GetString());
        var clientId = application.GetProperty("appId").GetGuid();
        var principal = await WaitForResourceAsync(client, credential,
            $"https://graph.microsoft.com/v1.0/servicePrincipals(appId='{clientId:D}')",
            resource => resource.GetProperty("appId").GetGuid() == clientId, cancellationToken);
 
        using var consumer = await ReadResourceAsync(client, credential,
            $"https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.App/containerApps/consumer?api-version=2026-03-02-preview",
            cancellationToken);
        Assert.NotNull(consumer);
        var properties = consumer.RootElement.GetProperty("properties");
        var container = Assert.Single(properties.GetProperty("template").GetProperty("containers").EnumerateArray());
        var environment = container.GetProperty("env").EnumerateArray().ToArray();
        Assert.Equal(tenantId.ToString("D"), Assert.Single(environment,
            entry => entry.GetProperty("name").GetString() == "AzureAd__TenantId").GetProperty("value").GetString());
        Assert.Equal(clientId.ToString("D"), Assert.Single(environment,
            entry => entry.GetProperty("name").GetString() == "AzureAd__ClientId").GetProperty("value").GetString());
        var ingress = properties.GetProperty("configuration").GetProperty("ingress");
        Assert.True(ingress.GetProperty("external").GetBoolean());
        Assert.False(ingress.GetProperty("allowInsecure").GetBoolean());
        var hostname = ingress.GetProperty("fqdn").GetString();
        Assert.Equal(UriHostNameType.Dns, Uri.CheckHostName(hostname));
        var url = $"https://{hostname}";
        await DotnetProjectDeploymentHelpers.VerifyResponseAsync(url, $"{tenantId:D}/{clientId:D}", cancellationToken);
        deploymentUrls["Consumer"] = url;
        return (application.GetProperty("id").GetGuid(), clientId, principal.GetProperty("id").GetGuid());
    }
 
    private static async Task<JsonElement> WaitForResourceAsync(
        HttpClient client, TokenCredential credential, string url, Func<JsonElement, bool> predicate,
        CancellationToken cancellationToken)
    {
        using var readiness = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        readiness.CancelAfter(TimeSpan.FromMinutes(2));
        while (true)
        {
            using var resource = await ReadResourceAsync(client, credential, url, readiness.Token);
            if (resource is not null && predicate(resource.RootElement))
            {
                return resource.RootElement.Clone();
            }
 
            // Graph reads can lag extension completion; poll the actual provider state.
            await Task.Delay(TimeSpan.FromSeconds(2), readiness.Token);
        }
    }
 
    private static async Task CleanupDirectoryAsync(HttpClient client, TokenCredential credential, string applicationUrl)
    {
        using var cleanup = new CancellationTokenSource(TimeSpan.FromMinutes(5));
        using var application = await ReadResourceAsync(client, credential, applicationUrl, cleanup.Token);
        if (application is null)
        {
            return;
        }
 
        var clientId = application.RootElement.GetProperty("appId").GetGuid();
        var objectId = application.RootElement.GetProperty("id").GetGuid();
        var principalUrl = $"https://graph.microsoft.com/v1.0/servicePrincipals(appId='{clientId:D}')";
        using var principal = await ReadResourceAsync(client, credential, principalUrl, cleanup.Token);
        // Attempt both deletes even if one fails, rather than relying on application's deletion cascade.
        var deletePrincipal = principal is null
            ? Task.CompletedTask
            : DeleteResourceAsync(client, credential,
                $"https://graph.microsoft.com/v1.0/servicePrincipals/{principal.RootElement.GetProperty("id").GetGuid():D}", cleanup.Token);
        await Task.WhenAll(deletePrincipal,
            DeleteResourceAsync(client, credential, $"https://graph.microsoft.com/v1.0/applications/{objectId:D}", cleanup.Token));
        await Task.WhenAll(
            WaitForDeletionAsync(client, credential, applicationUrl, cleanup.Token),
            WaitForDeletionAsync(client, credential, principalUrl, cleanup.Token));
    }
 
    private static async Task CleanupAsync(
        HttpClient client, TokenCredential credential, string resourceGroupUrl, string applicationUrl)
    {
        var failures = new List<Exception>();
        try
        {
            // Finish ARM teardown before Graph cleanup to stop pending deployments from creating
            // directory objects while they are being cleaned up.
            await CleanupResourceGroupAsync(client, credential, resourceGroupUrl);
        }
        catch (Exception ex)
        {
            failures.Add(ex);
        }
 
        try
        {
            await CleanupDirectoryAsync(client, credential, applicationUrl);
        }
        catch (Exception ex)
        {
            failures.Add(ex);
        }
 
        if (failures.Count > 0)
        {
            throw new AggregateException("Azure resource group or Entra directory cleanup failed.", failures);
        }
    }
 
    private static async Task CleanupResourceGroupAsync(HttpClient client, TokenCredential credential, string resourceGroupUrl)
    {
        using var cleanup = new CancellationTokenSource(TimeSpan.FromMinutes(15));
        await DeleteResourceAsync(client, credential, resourceGroupUrl, cleanup.Token);
        await WaitForDeletionAsync(client, credential, resourceGroupUrl, cleanup.Token);
    }
 
    private static async Task WaitForDeletionAsync(
        HttpClient client, TokenCredential credential, string url, CancellationToken cancellationToken)
    {
        while (true)
        {
            using var resource = await ReadResourceAsync(client, credential, url, cancellationToken);
            if (resource is null)
            {
                return;
            }
 
            await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
        }
    }
 
    private static async Task DeleteResourceAsync(HttpClient client, TokenCredential credential, string url, CancellationToken cancellationToken)
    {
        using var response = await SendAsync(client, credential, HttpMethod.Delete, url, cancellationToken);
        if (response.StatusCode != HttpStatusCode.NotFound)
        {
            response.EnsureSuccessStatusCode();
        }
    }
 
    private static async Task<JsonDocument?> ReadResourceAsync(
        HttpClient client, TokenCredential credential, string url, CancellationToken cancellationToken)
    {
        using var response = await SendAsync(client, credential, HttpMethod.Get, url, cancellationToken);
        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return null;
        }
 
        response.EnsureSuccessStatusCode();
        await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
        return await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
    }
 
    private static async Task<HttpResponseMessage> SendAsync(
        HttpClient client, TokenCredential credential, HttpMethod method, string url, CancellationToken cancellationToken)
    {
        var scope = new Uri(url).Host switch
        {
            "graph.microsoft.com" => "https://graph.microsoft.com/.default",
            "management.azure.com" => "https://management.azure.com/.default",
            _ => throw new InvalidOperationException("Unexpected deployment test endpoint.")
        };
        var token = await credential.GetTokenAsync(new TokenRequestContext([scope]), cancellationToken);
        using var request = new HttpRequestMessage(method, url);
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
        return await client.SendAsync(request, cancellationToken);
    }
}