File: AzureResourcePreparerTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Azure.Tests\Aspire.Hosting.Azure.Tests.csproj (Aspire.Hosting.Azure.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIREPIPELINES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
 
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Utils;
using Azure.Provisioning.KeyVault;
using Azure.Provisioning.SignalR;
using Azure.Provisioning.Storage;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using static Aspire.Hosting.Utils.AzureManifestUtils;
 
namespace Aspire.Hosting.Azure.Tests;
 
public class AzureResourcePreparerTests
{
    [Fact]
    public void AzureRoleAssignmentResourceThrowsWhenOwnerAndIdentityAreInconsistent()
    {
        var target = new TestProvisioningResource("target");
        var identity = new AzureUserAssignedIdentityResource("identity");
        var owner = new TestProvisioningResource("owner");
 
        Assert.Throws<ArgumentException>("identityResource", () =>
            new AzureRoleAssignmentResource("roles", target, owner, identityResource: null, _ => { }));
 
        Assert.Throws<ArgumentException>("ownerResource", () =>
            new AzureRoleAssignmentResource("roles", target, ownerResource: null, identity, _ => { }));
    }
 
    private sealed class TestProvisioningResource(string name) : AzureProvisioningResource(name, _ => { });
 
    [Theory]
    [InlineData(DistributedApplicationOperation.Publish)]
    [InlineData(DistributedApplicationOperation.Run)]
    public async Task ThrowsExceptionsIfRoleAssignmentUnsupported(DistributedApplicationOperation operation)
    {
        using var builder = TestDistributedApplicationBuilder.Create(operation);
 
        var storage = builder.AddAzureStorage("storage");
 
        builder.AddProject<Project>("api", launchProfileName: null)
            .WithRoleAssignments(storage, StorageBuiltInRole.StorageBlobDataReader);
 
        var app = builder.Build();
 
        if (operation == DistributedApplicationOperation.Publish)
        {
            var ex = Assert.Throws<InvalidOperationException>(app.Start);
            Assert.Contains("role assignments", ex.Message);
        }
        else
        {
            await app.StartAsync();
            // no exception is thrown in Run mode
        }
    }
 
    [Theory]
    [InlineData(true, DistributedApplicationOperation.Run)]
    [InlineData(false, DistributedApplicationOperation.Run)]
    [InlineData(true, DistributedApplicationOperation.Publish)]
    [InlineData(false, DistributedApplicationOperation.Publish)]
    public async Task AppliesDefaultRoleAssignmentsInRunModeIfReferenced(bool addContainerAppsInfra, DistributedApplicationOperation operation)
    {
        using var builder = TestDistributedApplicationBuilder.Create(operation);
        if (addContainerAppsInfra)
        {
            builder.AddAzureContainerAppEnvironment("env");
        }
 
        var storage = builder.AddAzureStorage("storage");
        var blobs = storage.AddBlobs("blobs");
 
        var api = builder.AddProject<Project>("api", launchProfileName: null)
            .WithReference(blobs);
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        await ExecuteBeforeStartHooksAsync(app, default);
 
        Assert.True(storage.Resource.TryGetLastAnnotation<DefaultRoleAssignmentsAnnotation>(out var defaultAssignments));
 
        if (!addContainerAppsInfra || operation == DistributedApplicationOperation.Run)
        {
            // when AzureContainerAppsInfrastructure is not added, we always apply the default role assignments to a new 'storage-roles' resource.
            // The same applies when in RunMode and we are provisioning Azure resources for F5 local development.
            var storageRoles = Assert.Single(model.Resources.OfType<AzureRoleAssignmentResource>(), r => r.Name == "storage-roles");
            Assert.Same(storage.Resource, storageRoles.TargetAzureResource);
            Assert.Null(storageRoles.OwnerResource);
 
            var storageRolesManifest = await GetManifestWithBicep(storageRoles, skipPreparer: true);
            await Verify(storageRolesManifest.BicepText, extension: "bicep");
 
        }
        else
        {
            // in PublishMode when AzureContainerAppsInfrastructure is added, the DefaultRoleAssignmentsAnnotation
            // is copied to referencing resources' RoleAssignmentAnnotation.
 
            Assert.True(api.Resource.TryGetLastAnnotation<RoleAssignmentAnnotation>(out var apiRoleAssignments));
            Assert.Equal(storage.Resource, apiRoleAssignments.Target);
            Assert.Equal(defaultAssignments.Roles, apiRoleAssignments.Roles);
        }
    }
 
    [Theory]
    [InlineData(DistributedApplicationOperation.Run)]
    [InlineData(DistributedApplicationOperation.Publish)]
    public async Task AppliesRoleAssignmentsInRunMode(DistributedApplicationOperation operation)
    {
        using var builder = TestDistributedApplicationBuilder.Create(operation);
        builder.AddAzureContainerAppEnvironment("env");
 
        var storage = builder.AddAzureStorage("storage");
        var blobs = storage.AddBlobs("blobs");
 
        var api = builder.AddProject<Project>("api", launchProfileName: null)
            .WithRoleAssignments(storage, StorageBuiltInRole.StorageBlobDelegator, StorageBuiltInRole.StorageBlobDataReader)
            .WithReference(blobs);
 
        var api2 = builder.AddProject<Project>("api2", launchProfileName: null)
            .WithRoleAssignments(storage, StorageBuiltInRole.StorageBlobDataContributor)
            .WithReference(blobs);
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        await ExecuteBeforeStartHooksAsync(app, default);
 
        if (operation == DistributedApplicationOperation.Run)
        {
            // in RunMode, we apply the role assignments to a new 'storage-roles' resource, so the provisioned resource
            // adds these role assignments for F5 local development.
            var storageRoles = Assert.Single(model.Resources.OfType<AzureRoleAssignmentResource>(), r => r.Name == "storage-roles");
            Assert.Same(storage.Resource, storageRoles.TargetAzureResource);
            Assert.Null(storageRoles.OwnerResource);
 
            var storageRolesManifest = await GetManifestWithBicep(storageRoles, skipPreparer: true);
            await Verify(storageRolesManifest.BicepText, extension: "bicep");
 
        }
        else
        {
            // in PublishMode, the role assignments are copied to the referencing resources' RoleAssignmentAnnotation.
            Assert.True(api.Resource.TryGetLastAnnotation<RoleAssignmentAnnotation>(out var apiRoleAssignments));
            Assert.Equal(storage.Resource, apiRoleAssignments.Target);
            Assert.Collection(apiRoleAssignments.Roles,
                role => Assert.Equal(StorageBuiltInRole.StorageBlobDelegator.ToString(), role.Id),
                role => Assert.Equal(StorageBuiltInRole.StorageBlobDataReader.ToString(), role.Id));
 
            Assert.True(api2.Resource.TryGetLastAnnotation<RoleAssignmentAnnotation>(out var api2RoleAssignments));
            Assert.Equal(storage.Resource, api2RoleAssignments.Target);
            Assert.Single(api2RoleAssignments.Roles,
                role => role.Id == StorageBuiltInRole.StorageBlobDataContributor.ToString());
        }
    }
 
    [Fact]
    public async Task DoesNotApplyRoleAssignmentsInRunModeForEmulators()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        builder.AddAzureContainerAppEnvironment("env");
 
        builder.AddBicepTemplateString("foo", "");
 
        var dbsrv = builder.AddAzureSqlServer("dbsrv").RunAsContainer();
        var db = dbsrv.AddDatabase("db");
 
        var api = builder.AddProject<Project>("api", launchProfileName: null)
            .WithReference(db);
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        await ExecuteBeforeStartHooksAsync(app, default);
 
        // in RunMode, we skip applying the role assignments to a new 'dbsrv-roles' resource, since the storage is running as emulator.
        Assert.DoesNotContain(model.Resources.OfType<AzureProvisioningResource>(), r => r.Name == "dbsrv-roles");
    }
 
    [Fact]
    public async Task FindsAzureReferencesFromArguments()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        builder.AddAzureContainerAppEnvironment("env");
 
        var storage = builder.AddAzureStorage("storage");
        var blobs = storage.AddBlobs("blobs");
 
        // the project doesn't WithReference or WithRoleAssignments, so it should get the default role assignments
        var api = builder.AddProject<Project>("api", launchProfileName: null)
            .WithArgs(context =>
            {
                context.Args.Add("--azure-blobs");
                context.Args.Add(blobs.Resource.ConnectionStringExpression);
            });
 
        using var app = builder.Build();
        await ExecuteBeforeStartHooksAsync(app, default);
 
        Assert.True(storage.Resource.TryGetLastAnnotation<DefaultRoleAssignmentsAnnotation>(out var defaultAssignments));
 
        Assert.True(api.Resource.TryGetLastAnnotation<RoleAssignmentAnnotation>(out var apiRoleAssignments));
        Assert.Equal(storage.Resource, apiRoleAssignments.Target);
        Assert.Equal(defaultAssignments.Roles, apiRoleAssignments.Roles);
    }
 
    [Fact]
    public async Task PublishDeploymentTargetIncludesComputedPrerequisitesInReferences()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        builder.AddAzureAppServiceEnvironment("env");
 
        var vnet = builder.AddAzureVirtualNetwork("vnet");
        var peSubnet = vnet.AddSubnet("pe-subnet", "10.0.1.0/24");
 
        var storage = builder.AddAzureStorage("storage");
        var blobs = storage.AddBlobs("blobs");
        var queues = storage.AddBlobs("queues");
 
        var blobPE = peSubnet.AddPrivateEndpoint(blobs);
        var queuesPE = peSubnet.AddPrivateEndpoint(queues);
 
        var api = builder.AddProject<Project>("api", launchProfileName: null)
            .WithReference(blobs)
            .WithReference(queues);
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        await ExecuteBeforeStartHooksAsync(app, default);
 
        var roleAssignmentResource = Assert.Single(model.Resources.OfType<AzureRoleAssignmentResource>(), r => r.Name == "api-roles-storage");
        var deploymentTarget = Assert.IsAssignableFrom<AzureBicepResource>(api.Resource.GetDeploymentTargetAnnotation()?.DeploymentTarget);
 
        Assert.Same(storage.Resource, roleAssignmentResource.TargetAzureResource);
        Assert.Same(api.Resource, roleAssignmentResource.OwnerResource);
        Assert.Contains(roleAssignmentResource, deploymentTarget.References);
        Assert.Contains(blobPE.Resource, deploymentTarget.References);
        Assert.Contains(queuesPE.Resource, deploymentTarget.References);
    }
 
    [Fact]
    public async Task PipelineStepAfterBeforeStartCanInspectRoleAssignmentsForTargetAzureResource()
    {
        const string inspectRoleAssignmentsStepName = "inspect-keyvault-role-assignments";
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: inspectRoleAssignmentsStepName);
        builder.AddAzureContainerAppEnvironment("env");
 
        var keyVault = builder.AddAzureKeyVault("keyvault");
        var storage = builder.AddAzureStorage("storage");
 
        var api = builder.AddProject<Project>("api", launchProfileName: null)
            .WithRoleAssignments(keyVault, KeyVaultBuiltInRole.KeyVaultSecretsUser)
            .WithRoleAssignments(storage, StorageBuiltInRole.StorageBlobDataReader);
 
        var worker = builder.AddProject<Project>("worker", launchProfileName: null)
            .WithRoleAssignments(keyVault, KeyVaultBuiltInRole.KeyVaultSecretsUser);
 
        List<AzureRoleAssignmentResource>? keyVaultRoleAssignments = null;
        builder.Pipeline.AddStep(
            inspectRoleAssignmentsStepName,
            context =>
            {
                keyVaultRoleAssignments =
                    [.. context.Model.Resources
                        .OfType<AzureRoleAssignmentResource>()
                        .Where(resource => resource.TargetAzureResource == keyVault.Resource)
                        .OrderBy(resource => resource.Name, StringComparer.Ordinal)];
 
                return Task.CompletedTask;
            },
            dependsOn: WellKnownPipelineSteps.BeforeStart);
 
        using var app = builder.Build();
        await ExecutePipelineAsync(app);
 
        Assert.NotNull(keyVaultRoleAssignments);
        Assert.Collection(keyVaultRoleAssignments,
            resource =>
            {
                Assert.Equal("api-roles-keyvault", resource.Name);
                Assert.Same(keyVault.Resource, resource.TargetAzureResource);
                Assert.Same(api.Resource, resource.OwnerResource);
            },
            resource =>
            {
                Assert.Equal("worker-roles-keyvault", resource.Name);
                Assert.Same(keyVault.Resource, resource.TargetAzureResource);
                Assert.Same(worker.Resource, resource.OwnerResource);
            });
 
        var storageRoleAssignment = Assert.Single(
            app.Services.GetRequiredService<DistributedApplicationModel>().Resources.OfType<AzureRoleAssignmentResource>(),
            resource => resource.TargetAzureResource == storage.Resource);
        Assert.Same(api.Resource, storageRoleAssignment.OwnerResource);
    }
 
    [Fact]
    public async Task RoleAssignmentsAreNotDuplicatedWhenBeforeStartRunsBeforePublishPipeline()
    {
        const string inspectRoleAssignmentsStepName = "inspect-signalr-role-assignments";
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: inspectRoleAssignmentsStepName);
        builder.AddAzureContainerAppEnvironment("env");
 
        var signalR = builder.AddAzureSignalR("signalr");
 
        var serviceA = builder.AddProject<Project>("service-a", launchProfileName: null)
            .WithReference(signalR)
            .WithRoleAssignments(signalR, SignalRBuiltInRole.SignalRContributor);
 
        var serviceB = builder.AddProject<Project>("service-b", launchProfileName: null)
            .WithReference(signalR)
            .WithRoleAssignments(signalR, SignalRBuiltInRole.SignalRContributor);
 
        List<AzureRoleAssignmentResource>? signalRRoleAssignments = null;
        builder.Pipeline.AddStep(
            inspectRoleAssignmentsStepName,
            context =>
            {
                signalRRoleAssignments =
                    [.. context.Model.Resources
                        .OfType<AzureRoleAssignmentResource>()
                        .Where(resource => resource.TargetAzureResource == signalR.Resource)
                        .OrderBy(resource => resource.Name, StringComparer.Ordinal)];
 
                return Task.CompletedTask;
            },
            dependsOn: WellKnownPipelineSteps.BeforeStart);
 
        using var app = builder.Build();
        await app.RunAsync().WaitAsync(TimeSpan.FromSeconds(30));
 
        Assert.NotNull(signalRRoleAssignments);
        Assert.Collection(signalRRoleAssignments,
            resource =>
            {
                Assert.Equal("service-a-roles-signalr", resource.Name);
                Assert.Same(signalR.Resource, resource.TargetAzureResource);
                Assert.Same(serviceA.Resource, resource.OwnerResource);
            },
            resource =>
            {
                Assert.Equal("service-b-roles-signalr", resource.Name);
                Assert.Same(signalR.Resource, resource.TargetAzureResource);
                Assert.Same(serviceB.Resource, resource.OwnerResource);
            });
 
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        Assert.Collection(
            model.Resources
                .OfType<ProjectResource>()
                .OrderBy(resource => resource.Name, StringComparer.Ordinal),
            resource => Assert.Single(resource.Annotations.OfType<DeploymentPrerequisitesAnnotation>()),
            resource => Assert.Single(resource.Annotations.OfType<DeploymentPrerequisitesAnnotation>()));
    }
 
    [Fact]
    public async Task GlobalRoleAssignmentsAreNotDuplicatedWhenBeforeStartRunsTwice()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
 
        var storage = builder.AddAzureStorage("storage");
        var blobs = storage.AddBlobs("blobs");
 
        builder.AddProject<Project>("api", launchProfileName: null)
            .WithReference(blobs);
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        await ExecuteBeforeStartHooksAsync(app, default);
        await ExecuteBeforeStartHooksAsync(app, default);
 
        var storageRoles = Assert.Single(model.Resources.OfType<AzureRoleAssignmentResource>(), resource => resource.Name == "storage-roles");
        Assert.Same(storage.Resource, storageRoles.TargetAzureResource);
        Assert.Null(storageRoles.OwnerResource);
        Assert.Single(storage.Resource.Annotations.OfType<RoleAssignmentResourceAnnotation>());
        Assert.Single(storageRoles.Annotations.OfType<ResourceRelationshipAnnotation>());
    }
 
    [Fact]
    public async Task NullEnvironmentVariableIsIgnored()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        builder.AddAzureContainerAppEnvironment("env");
 
        var storage = builder.AddAzureStorage("storage");
 
        // Create a project with an environment variable callback that sets a null value
        var api = builder.AddProject<Project>("api", launchProfileName: null)
            .WithEnvironment(context =>
            {
                // This simulates the issue where a callback adds a null value
                context.EnvironmentVariables["NULL_ENV"] = null!;
                context.EnvironmentVariables["VALID_ENV"] = "valid_value";
            });
 
        using var app = builder.Build();
 
        // This should not throw a NullReferenceException
        await ExecuteBeforeStartHooksAsync(app, default);
 
        // Test passes if we reach this point without exceptions
        Assert.True(true);
    }
 
    [Fact]
    public async Task NullCommandLineArgIsIgnored()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        builder.AddAzureContainerAppEnvironment("env");
 
        var storage = builder.AddAzureStorage("storage");
 
        // Create a project with a command line args callback that adds a null value
        var api = builder.AddProject<Project>("api", launchProfileName: null)
            .WithArgs(context =>
            {
                // This simulates the issue where a callback adds a null value
                context.Args.Add("--valid-arg");
                context.Args.Add(null!);
                context.Args.Add("another-valid-arg");
            });
 
        using var app = builder.Build();
 
        // This should not throw a NullReferenceException
        await ExecuteBeforeStartHooksAsync(app, default);
 
        // Test passes if we reach this point without exceptions
        Assert.True(true);
    }
 
    [Fact]
    public async Task CommandLineArgsCallbackContextHasCorrectExecutionContextDuringPublish()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        builder.AddAzureContainerAppEnvironment("env");
 
        DistributedApplicationExecutionContext? capturedExecutionContext = null;
 
        // Create a project with a WithArgs callback that captures the ExecutionContext
        var api = builder.AddProject<Project>("api", launchProfileName: null)
            .WithArgs(context =>
            {
                // Capture the ExecutionContext to verify it's set correctly
                capturedExecutionContext = context.ExecutionContext;
            });
 
        using var app = builder.Build();
 
        // This should not throw - the ExecutionContext should be set correctly
        await ExecuteBeforeStartHooksAsync(app, default);
 
        // Verify the ExecutionContext was captured and is in Publish mode
        Assert.NotNull(capturedExecutionContext);
        Assert.True(capturedExecutionContext.IsPublishMode);
        Assert.False(capturedExecutionContext.IsRunMode);
    }
 
    /// <summary>
    /// Ensures that role assignments are only applied to direct references and not transitive ones.
    /// </summary>
    [Fact]
    public async Task AppliesRoleAssignmentsOnlyToDirectReferences()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        builder.AddAzureContainerAppEnvironment("env");
 
        var storage = builder.AddAzureStorage("storage");
        var blobs = storage.AddBlobs("blobs");
 
        var api = builder.AddProject<Project>("api", launchProfileName: null)
            .WithHttpEndpoint()
            .WithReference(blobs);
 
        var api2 = builder.AddProject<Project>("api2", launchProfileName: null)
            .WithReference(api);
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        await ExecuteBeforeStartHooksAsync(app, default);
 
        Assert.Collection(model.Resources.Select(r => r.Name),
            n => Assert.StartsWith("azure", n),
            n => Assert.Equal("env-acr", n),
            n => Assert.Equal("env", n),
            n => Assert.Equal("storage", n),
            n => Assert.Equal("blobs", n),
            n => Assert.Equal("api", n),
            n => Assert.Equal("api2", n),
            n => Assert.Equal("api-identity", n),
            n => Assert.Equal("api-roles-storage", n));
    }
 
    [Fact]
    public async Task ViteAppDoesNotGetManagedIdentity()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        builder.AddAzureContainerAppEnvironment("env");
 
        var storage = builder.AddAzureStorage("storage");
        var blobs = storage.AddBlobs("blobs");
 
        var api = builder.AddProject<Project>("api", launchProfileName: null)
            .WithHttpEndpoint()
            .WithReference(blobs)
            .WaitFor(blobs);
 
        var frontend = builder.AddViteApp("frontend", "./frontend")
            .WithReference(api)
            .WithReference(blobs)
            .WaitFor(blobs);
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        await ExecuteBeforeStartHooksAsync(app, default);
 
        Assert.Collection(model.Resources.Select(r => r.Name),
            n => Assert.StartsWith("azure", n),
            n => Assert.Equal("env-acr", n),
            n => Assert.Equal("env", n),
            n => Assert.Equal("storage", n),
            n => Assert.Equal("blobs", n),
            n => Assert.Equal("api", n),
            n => Assert.Equal("frontend", n),
            n => Assert.Equal("api-identity", n),
            n => Assert.Equal("api-roles-storage", n));
 
        // The ViteApp should NOT get a managed identity since it is a BuildOnlyContainer resource,
        // even though it references the storage account. Only the API should get a managed identity.
        Assert.DoesNotContain(model.Resources, r => r.Name == "frontend-identity");
    }
 
    [Fact]
    public async Task ReferenceRoleAssignmentAnnotation_PublishMode_GrantsRolesOnImpliedTargetToConsumer()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        builder.AddAzureContainerAppEnvironment("env");
 
        var storage = builder.AddAzureStorage("storage");
 
        // A non-Azure compute resource (the "agent" node app) that fronts the storage account: any
        // resource referencing it should be granted a role on storage even though storage is only a
        // transitive dependency that the IAzureResource-only reference walk cannot reach.
        var agent = builder.AddContainer("agent", "img:latest")
            .WithHttpEndpoint();
        agent.Resource.Annotations.Add(new ReferenceRoleAssignmentAnnotation(
            storage.Resource,
            new HashSet<RoleDefinition> { new(StorageBuiltInRole.StorageBlobDataReader.ToString(), nameof(StorageBuiltInRole.StorageBlobDataReader)) }));
 
        var consumer = builder.AddProject<Project>("api", launchProfileName: null)
            .WithReference(agent.GetEndpoint("http"));
 
        using var app = builder.Build();
        await ExecuteBeforeStartHooksAsync(app, default);
 
        Assert.True(consumer.Resource.TryGetLastAnnotation<RoleAssignmentAnnotation>(out var consumerRoleAssignments));
        Assert.Equal(storage.Resource, consumerRoleAssignments.Target);
        Assert.Single(consumerRoleAssignments.Roles, role => role.Id == StorageBuiltInRole.StorageBlobDataReader.ToString());
    }
 
    [Fact]
    public async Task ReferenceRoleAssignmentAnnotation_RunMode_AppliesRolesToGlobalRolesResource()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        builder.AddAzureContainerAppEnvironment("env");
 
        var storage = builder.AddAzureStorage("storage");
 
        var agent = builder.AddContainer("agent", "img:latest")
            .WithHttpEndpoint();
        agent.Resource.Annotations.Add(new ReferenceRoleAssignmentAnnotation(
            storage.Resource,
            new HashSet<RoleDefinition> { new(StorageBuiltInRole.StorageBlobDataReader.ToString(), nameof(StorageBuiltInRole.StorageBlobDataReader)) }));
 
        builder.AddProject<Project>("api", launchProfileName: null)
            .WithReference(agent.GetEndpoint("http"));
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        await ExecuteBeforeStartHooksAsync(app, default);
 
        var storageRoles = Assert.Single(model.Resources.OfType<AzureRoleAssignmentResource>(), r => r.Name == "storage-roles");
        Assert.Same(storage.Resource, storageRoles.TargetAzureResource);
    }
 
    [Fact]
    public async Task ReferenceRoleAssignmentAnnotation_ConsumerNotReferencingFrontingResource_GetsNoRole()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        builder.AddAzureContainerAppEnvironment("env");
 
        var storage = builder.AddAzureStorage("storage");
 
        var agent = builder.AddContainer("agent", "img:latest")
            .WithHttpEndpoint();
        agent.Resource.Annotations.Add(new ReferenceRoleAssignmentAnnotation(
            storage.Resource,
            new HashSet<RoleDefinition> { new(StorageBuiltInRole.StorageBlobDataReader.ToString(), nameof(StorageBuiltInRole.StorageBlobDataReader)) }));
 
        // This compute resource does not reference the fronting agent, so it must not be granted any
        // role on the implied storage target.
        var bystander = builder.AddProject<Project>("bystander", launchProfileName: null);
 
        using var app = builder.Build();
        await ExecuteBeforeStartHooksAsync(app, default);
 
        Assert.False(bystander.Resource.TryGetLastAnnotation<RoleAssignmentAnnotation>(out _));
    }
 
    [Fact]
    public async Task ReferenceRoleAssignmentAnnotation_SameTargetFromTwoDependencies_DedupesRoles()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        builder.AddAzureContainerAppEnvironment("env");
 
        var storage = builder.AddAzureStorage("storage");
 
        // Two fronting resources (e.g. two hosted agents on the same Foundry account) each imply the
        // same role on the same target. A consumer referencing both must end up with a single role
        // assignment for that role, otherwise two RoleAssignment bicep resources collide on the same
        // identifier ("{prefix}_{roleName}") and bicep compilation fails.
        var role = new HashSet<RoleDefinition> { new(StorageBuiltInRole.StorageBlobDataReader.ToString(), nameof(StorageBuiltInRole.StorageBlobDataReader)) };
 
        var agent1 = builder.AddContainer("agent1", "img:latest").WithHttpEndpoint();
        agent1.Resource.Annotations.Add(new ReferenceRoleAssignmentAnnotation(storage.Resource, role));
 
        var agent2 = builder.AddContainer("agent2", "img:latest").WithHttpEndpoint();
        agent2.Resource.Annotations.Add(new ReferenceRoleAssignmentAnnotation(storage.Resource, role));
 
        var consumer = builder.AddProject<Project>("api", launchProfileName: null)
            .WithReference(agent1.GetEndpoint("http"))
            .WithReference(agent2.GetEndpoint("http"));
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        await ExecuteBeforeStartHooksAsync(app, default);
 
        var roleAssignmentResource = Assert.Single(model.Resources.OfType<AzureRoleAssignmentResource>(), r => r.Name == "api-roles-storage");
        Assert.Same(storage.Resource, roleAssignmentResource.TargetAzureResource);
        Assert.Same(consumer.Resource, roleAssignmentResource.OwnerResource);
 
        // The generated bicep must contain exactly one role assignment, not two duplicates of the same role.
        var manifest = await GetManifestWithBicep(roleAssignmentResource, skipPreparer: true);
        var roleAssignmentCount = System.Text.RegularExpressions.Regex.Matches(manifest.BicepText, "Microsoft.Authorization/roleAssignments@").Count;
        Assert.Equal(1, roleAssignmentCount);
    }
 
    [Fact]
    public async Task ReferenceRoleAssignmentAnnotation_ConsumerWithExplicitRoleAssignment_DoesNotReintroduceSuppressedDefaults()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        builder.AddAzureContainerAppEnvironment("env");
 
        var storage = builder.AddAzureStorage("storage");
        var blobs = storage.AddBlobs("blobs");
 
        // A fronting resource (e.g. a Foundry hosted agent's node app) implies the Reader role on storage.
        var agent = builder.AddContainer("agent", "img:latest").WithHttpEndpoint();
        agent.Resource.Annotations.Add(new ReferenceRoleAssignmentAnnotation(
            storage.Resource,
            new HashSet<RoleDefinition> { new(StorageBuiltInRole.StorageBlobDataReader.ToString(), nameof(StorageBuiltInRole.StorageBlobDataReader)) }));
 
        // The consumer references storage directly (so its default role assignments would normally be
        // applied) but declares an explicit WithRoleAssignments, which intentionally suppresses those
        // defaults. The implied-reference hook must add only its Reader role and must NOT re-introduce the
        // suppressed defaults - that was the "pit of failure" the union-of-defaults pattern would have caused.
        var consumer = builder.AddProject<Project>("api", launchProfileName: null)
            .WithRoleAssignments(storage, StorageBuiltInRole.StorageBlobDelegator)
            .WithReference(blobs)
            .WithReference(agent.GetEndpoint("http"));
 
        using var app = builder.Build();
        await ExecuteBeforeStartHooksAsync(app, default);
 
        var roleIds = consumer.Resource.Annotations.OfType<RoleAssignmentAnnotation>()
            .Where(a => a.Target == storage.Resource)
            .SelectMany(a => a.Roles)
            .Select(r => r.Id)
            .ToHashSet();
 
        // Explicit role + implied Reader are present.
        Assert.Contains(StorageBuiltInRole.StorageBlobDelegator.ToString(), roleIds);
        Assert.Contains(StorageBuiltInRole.StorageBlobDataReader.ToString(), roleIds);
 
        // The suppressed defaults must not be re-introduced by the implied-reference hook.
        Assert.DoesNotContain(StorageBuiltInRole.StorageBlobDataContributor.ToString(), roleIds);
        Assert.DoesNotContain(StorageBuiltInRole.StorageTableDataContributor.ToString(), roleIds);
        Assert.DoesNotContain(StorageBuiltInRole.StorageQueueDataContributor.ToString(), roleIds);
    }
 
    [Fact]
    public async Task ReferenceRoleAssignmentAnnotation_ConsumerWithDirectReference_KeepsDefaultsAndAddsImpliedRole()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        builder.AddAzureContainerAppEnvironment("env");
 
        var storage = builder.AddAzureStorage("storage");
        var blobs = storage.AddBlobs("blobs");
 
        // A fronting resource (e.g. a Foundry hosted agent's node app) implies the Reader role on storage.
        var agent = builder.AddContainer("agent", "img:latest").WithHttpEndpoint();
        agent.Resource.Annotations.Add(new ReferenceRoleAssignmentAnnotation(
            storage.Resource,
            new HashSet<RoleDefinition> { new(StorageBuiltInRole.StorageBlobDataReader.ToString(), nameof(StorageBuiltInRole.StorageBlobDataReader)) }));
 
        // The consumer references storage directly without declaring explicit role assignments, so the
        // account defaults still apply. The implied-reference hook must add its Reader role alongside the
        // defaults (the caller - the preparer - owns defaults; the hook never replaces or removes them).
        var consumer = builder.AddProject<Project>("api", launchProfileName: null)
            .WithReference(blobs)
            .WithReference(agent.GetEndpoint("http"));
 
        using var app = builder.Build();
        await ExecuteBeforeStartHooksAsync(app, default);
 
        var roleIds = consumer.Resource.Annotations.OfType<RoleAssignmentAnnotation>()
            .Where(a => a.Target == storage.Resource)
            .SelectMany(a => a.Roles)
            .Select(r => r.Id)
            .ToHashSet();
 
        // Defaults are preserved by the preparer's normal reference walk.
        Assert.Contains(StorageBuiltInRole.StorageBlobDataContributor.ToString(), roleIds);
        Assert.Contains(StorageBuiltInRole.StorageTableDataContributor.ToString(), roleIds);
        Assert.Contains(StorageBuiltInRole.StorageQueueDataContributor.ToString(), roleIds);
 
        // The implied Reader role is added on top of the defaults.
        Assert.Contains(StorageBuiltInRole.StorageBlobDataReader.ToString(), roleIds);
    }
 
    private sealed class Project : IProjectMetadata
    {
        public string ProjectPath => "project";
    }
 
    private static Task ExecutePipelineAsync(DistributedApplication app)
    {
        var pipeline = app.Services.GetRequiredService<IDistributedApplicationPipeline>();
        var context = new PipelineContext(
            app.Services.GetRequiredService<DistributedApplicationModel>(),
            app.Services.GetRequiredService<DistributedApplicationExecutionContext>(),
            app.Services,
            app.Services.GetRequiredService<ILogger<AzureResourcePreparerTests>>(),
            CancellationToken.None);
 
        return pipeline.ExecuteAsync(context);
    }
}