// 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.
#pragma warning disable ASPIREPIPELINES001 // Verifies provisioning annotations on existing resources.
#pragma warning disable ASPIREPIPELINES003 // Exercises the deployment pipeline for existing registrations.
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Azure.EntraId.Provisioning;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using Aspire.Tests.Utils;
using Azure.Provisioning;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace Aspire.Hosting.Azure.EntraId.Tests;
public class EntraIdProvisioningTests(ITestOutputHelper outputHelper)
{
private const string TenantId = "aaaabbbb-0000-cccc-1111-dddd2222eeee";
private const string ClientId = "00001111-aaaa-2222-bbbb-3333cccc4444";
[Fact]
public async Task AddEntraIdApplication_GeneratesGraphBicep()
{
using var builder = TestDistributedApplicationBuilder.Create();
var entra = builder.AddEntraIdApplication("entra-web");
Assert.IsAssignableFrom<AzureProvisioningResource>(entra.Resource);
var tenantScoped = Assert.Single(entra.Resource.Annotations.OfType<TenantScopedResourceAnnotation>());
Assert.Equal(new Dictionary<string, string>
{
["Microsoft.Graph/applications"] = "objectId",
["Microsoft.Graph/servicePrincipals"] = "servicePrincipalId"
}, tenantScoped.ResourceIdOutputs);
Assert.NotNull(entra.Resource.ProvisioningTaskCompletionSource);
Assert.False(entra.Resource.ProvisioningTaskCompletionSource.Task.IsCompleted);
Assert.Equal(new[] { "tenantResourceSuffix" }, entra.Resource.Parameters.Keys);
Assert.Equal(EntraIdSignInAudience.AzureADMyOrg, entra.Resource.SignInAudience);
Assert.Null(entra.Resource.Parameters[AzureBicepResource.KnownParameters.TenantResourceSuffix]);
Assert.Single(builder.Resources.OfType<AzureEnvironmentResource>());
await Verify(entra.Resource.GetBicepTemplateString(), "bicep");
Assert.Equal(new[] { "tenantResourceSuffix" }, entra.Resource.Parameters.Keys);
}
[Fact]
public async Task ConfigureInfrastructure_CustomizesGraphResources()
{
using var builder = TestDistributedApplicationBuilder.Create();
var entra = builder.AddEntraIdApplication("entra-web")
.ConfigureInfrastructure(infrastructure =>
{
var application = Assert.Single(infrastructure.GetProvisionableResources().OfType<MicrosoftGraphApplication>());
var servicePrincipal = Assert.Single(infrastructure.GetProvisionableResources().OfType<MicrosoftGraphServicePrincipal>());
application.DisplayName = "Contoso's web application";
application.RedirectUris = ["https://localhost:7123/signin-oidc", "https://localhost:7123/auth/callback"];
application.EnableIdTokenIssuance = true;
Assert.Equal("application.appId", servicePrincipal.AppId.Compile().ToString());
infrastructure.Add(new ProvisioningOutput("applicationDisplayName", typeof(string))
{
Value = application.DisplayName.ToBicepExpression()
});
});
await Verify(entra.Resource.GetBicepTemplateString(), "bicep");
}
[Fact]
public void GetBicepTemplateFile_IsIdempotent()
{
using var builder = TestDistributedApplicationBuilder.Create();
var entra = builder.AddEntraIdApplication("entra-web")
.WithRedirectUris("https://localhost:7123/signin-oidc");
using var workspace = TemporaryWorkspace.Create(outputHelper);
using var first = entra.Resource.GetBicepTemplateFile(workspace.Path);
var firstContents = File.ReadAllText(first.Path);
using var second = entra.Resource.GetBicepTemplateFile(workspace.Path);
var secondContents = File.ReadAllText(second.Path);
Assert.Equal(firstContents, secondContents);
Assert.Equal(firstContents, entra.Resource.GetBicepTemplateString());
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void GetBicepTemplateString_RejectsNonResourceGroupScope(bool tenantScoped)
{
using var builder = TestDistributedApplicationBuilder.Create();
var entra = builder.AddEntraIdApplication("entra-web");
entra.Resource.Scope = tenantScoped
? AzureBicepResourceScope.CreateForTenant()
: AzureBicepResourceScope.CreateForSubscription("11111111-2222-3333-4444-555555555555");
var exception = Assert.Throws<InvalidOperationException>(entra.Resource.GetBicepTemplateString);
Assert.Equal("Entra ID application templates must be deployed at resource-group scope.", exception.Message);
}
[Fact]
public async Task AddEntraIdApplication_PublishesManifestWithParameters()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var entra = builder.AddEntraIdApplication("entra-web")
.WithSignInAudience(EntraIdSignInAudience.AzureADMultipleOrgs)
.WithRedirectUris("https://localhost:7123/signin-oidc")
.WithIdTokenIssuance();
using var workspace = TemporaryWorkspace.Create(outputHelper);
var manifest = await ManifestUtils.GetManifest(entra.Resource, workspace.Path);
Assert.Equal(new[] { "tenantResourceSuffix" }, entra.Resource.Parameters.Keys);
await Verify(manifest.ToJsonString(), "json")
.AppendContentAsFile(entra.Resource.GetBicepTemplateString(), "bicep");
}
[Fact]
public async Task Publish_SignInOnlyRegistration_RequiresExplicitIdTokenIssuance()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputHelper, workspace.Path);
var web = builder.AddEntraIdApplication("entra-web")
.WithRedirectUris(
"https://localhost:7123/signin-oidc",
"https://localhost:7123/signout-callback-oidc")
.WithIdTokenIssuance();
using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var environment = model.Resources.OfType<AzureEnvironmentResource>().Single();
var options = app.Services.GetRequiredService<IOptions<AzureProvisioningOptions>>().Value;
await using var step = await new TestPipelineActivityReporter(outputHelper).CreateStepAsync("Publishing sign-in-only registration");
var context = new AzurePublishingContext(
workspace.Path, options, app.Services, NullLogger<AzurePublishingContext>.Instance, step);
await context.WriteModelAsync(model, environment);
await Verify(await File.ReadAllTextAsync(Path.Combine(workspace.Path, "main.bicep")), "bicep")
.AppendContentAsFile(await File.ReadAllTextAsync(Path.Combine(workspace.Path, "entra-web", "entra-web.bicep")), "bicep");
}
[Fact]
public async Task Publish_TenantResourceSuffix_IsADeploymentParameterWithExplicitOverrides()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputHelper, workspace.Path);
builder.Configuration["Azure:TenantResourceSuffix"] = "local-developer";
builder.AddEntraIdApplication("entra-web");
builder.AddEntraIdApplication("entra-api")
.WithParameter(AzureBicepResource.KnownParameters.TenantResourceSuffix, "production");
builder.AddExistingEntraApplication("existing", tenantId: TenantId, clientId: ClientId);
using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var environment = Assert.Single(model.Resources.OfType<AzureEnvironmentResource>());
await using var step = await new TestPipelineActivityReporter(outputHelper).CreateStepAsync("Publishing tenant suffix");
var context = new AzurePublishingContext(
workspace.Path,
app.Services.GetRequiredService<IOptions<AzureProvisioningOptions>>().Value,
app.Services,
NullLogger<AzurePublishingContext>.Instance,
step);
await context.WriteModelAsync(model, environment);
await Verify(await File.ReadAllTextAsync(Path.Combine(workspace.Path, "main.bicep")), "bicep")
.AppendContentAsFile(await File.ReadAllTextAsync(Path.Combine(workspace.Path, "entra-web", "entra-web.bicep")), "bicep");
}
[Fact]
public async Task Publish_TenantResourceSuffix_ReusesExplicitNamedDeploymentParameter()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputHelper, workspace.Path);
var suffix = builder.AddParameter("tenantResourceSuffix", "shared");
builder.AddEntraIdApplication("entra-web");
builder.AddEntraIdApplication("entra-api")
.WithParameter(AzureBicepResource.KnownParameters.TenantResourceSuffix, suffix);
using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
await using var step = await new TestPipelineActivityReporter(outputHelper).CreateStepAsync("Publishing shared tenant suffix");
var context = new AzurePublishingContext(
workspace.Path,
app.Services.GetRequiredService<IOptions<AzureProvisioningOptions>>().Value,
app.Services,
NullLogger<AzurePublishingContext>.Instance,
step);
await context.WriteModelAsync(model, Assert.Single(model.Resources.OfType<AzureEnvironmentResource>()));
Assert.Same(context.ParameterLookup[suffix.Resource],
Assert.Single(context.MainInfrastructure.GetProvisionableResources().OfType<ProvisioningParameter>(),
parameter => parameter.BicepIdentifier == AzureBicepResource.KnownParameters.TenantResourceSuffix));
await Verify(await File.ReadAllTextAsync(Path.Combine(workspace.Path, "main.bicep")), "bicep");
}
[Fact]
public async Task Publish_CreatesSelfContainedGraphModulesAndOutputReferences()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputHelper, workspace.Path);
var api = builder.AddEntraIdApplication("entra-api");
var web = builder.AddEntraIdApplication("entra-web")
.WithSignInAudience(EntraIdSignInAudience.AzureADMultipleOrgs)
.WithRedirectUris("https://localhost:7123/signin-oidc");
builder.AddExistingEntraApplication("existing", tenantId: TenantId, clientId: ClientId);
builder.AddResource(new ProjectResource("web")).WithReference(web);
builder.AddResource(new ProjectResource("api")).WithReference(api);
builder.AddBicepTemplateString("consumer",
"""
param location string = resourceGroup().location
param tenantId string
param clientId string
output authority string = 'https://login.microsoftonline.com/${tenantId}/v2.0'
output applicationClientId string = clientId
""")
.WithParameter("tenantId", web.Resource.TenantIdOutputReference)
.WithParameter("clientId", web.Resource.ClientIdOutputReference);
using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var environment = model.Resources.OfType<AzureEnvironmentResource>().Single();
var options = app.Services.GetRequiredService<IOptions<AzureProvisioningOptions>>().Value;
await using var step = await new TestPipelineActivityReporter(outputHelper).CreateStepAsync("Publishing Entra registrations");
var context = new AzurePublishingContext(
workspace.Path, options, app.Services, NullLogger<AzurePublishingContext>.Instance, step);
await context.WriteModelAsync(model, environment);
var mainBicep = await File.ReadAllTextAsync(Path.Combine(workspace.Path, "main.bicep"));
var apiBicep = await File.ReadAllTextAsync(Path.Combine(workspace.Path, "entra-api", "entra-api.bicep"));
var webBicep = await File.ReadAllTextAsync(Path.Combine(workspace.Path, "entra-web", "entra-web.bicep"));
var consumerBicep = await File.ReadAllTextAsync(Path.Combine(workspace.Path, "consumer", "consumer.bicep"));
Assert.Equal(new[] { "consumer", "entra-api", "entra-web" }, Directory.GetDirectories(workspace.Path).Select(Path.GetFileName).Order());
await Verify(mainBicep, "bicep")
.AppendContentAsFile(apiBicep, "bicep")
.AppendContentAsFile(webBicep, "bicep")
.AppendContentAsFile(consumerBicep, "bicep");
}
[Theory]
[InlineData(EntraIdSignInAudience.AzureADMyOrg)]
[InlineData(EntraIdSignInAudience.AzureADMultipleOrgs)]
[InlineData(EntraIdSignInAudience.AzureADandPersonalMicrosoftAccount)]
[InlineData(EntraIdSignInAudience.PersonalMicrosoftAccount)]
public async Task WithSignInAudience_WritesResourceValueToInfrastructure(EntraIdSignInAudience audience)
{
using var builder = TestDistributedApplicationBuilder.Create();
var entra = builder.AddEntraIdApplication("entra-web");
entra.WithSignInAudience(audience);
Assert.Equal(audience, entra.Resource.SignInAudience);
Assert.Equal(new[] { "tenantResourceSuffix" }, entra.Resource.Parameters.Keys);
await Verify(entra.Resource.GetBicepTemplateString(), "bicep");
}
[Theory]
[InlineData(DistributedApplicationOperation.Run)]
[InlineData(DistributedApplicationOperation.Publish)]
public async Task WithIdTokenIssuance_WritesResourceValueToInfrastructure(DistributedApplicationOperation operation)
{
using var builder = TestDistributedApplicationBuilder.Create(operation);
var entra = builder.AddEntraIdApplication("entra-web");
Assert.Same(entra, entra.WithIdTokenIssuance());
Assert.Same(entra, entra.WithIdTokenIssuance());
Assert.True(entra.Resource.EnableIdTokenIssuance);
Assert.Equal(new[] { "tenantResourceSuffix" }, entra.Resource.Parameters.Keys);
await Verify(entra.Resource.GetBicepTemplateString(), "bicep");
}
[Fact]
public void WithRedirectUris_ValidatesAndAccumulatesResourceValues()
{
using var builder = TestDistributedApplicationBuilder.Create();
var entra = builder.AddEntraIdApplication("entra-web");
Assert.Same(entra, entra.WithRedirectUris("https://localhost:7123/signin-oidc"));
entra.WithRedirectUris("https://localhost:7123/signout-callback-oidc");
Assert.Throws<ArgumentNullException>(() => EntraIdResourceExtensions.WithRedirectUris(null!, "https://localhost:7123/signin-oidc"));
Assert.Throws<ArgumentNullException>(() => entra.WithRedirectUris(null!));
Assert.Throws<ArgumentException>(() => entra.WithRedirectUris("https://localhost:7252/callback", ""));
Assert.Equal(new[] { "https://localhost:7123/signin-oidc", "https://localhost:7123/signout-callback-oidc" }, entra.Resource.RedirectUris);
}
[Fact]
public async Task ConfigureInfrastructure_WritesDirectResourcePropertyValues()
{
using var builder = TestDistributedApplicationBuilder.Create();
var entra = builder.AddEntraIdApplication("entra-web");
entra.Resource.SignInAudience = EntraIdSignInAudience.AzureADMultipleOrgs;
entra.Resource.RedirectUris.Add("https://localhost:7123/signin-oidc");
entra.Resource.EnableIdTokenIssuance = true;
await Verify(entra.Resource.GetBicepTemplateString(), "bicep");
}
[Fact]
public void WithIdTokenIssuance_ThrowsWhenBuilderIsNull()
{
var exception = Assert.Throws<ArgumentNullException>(() => EntraIdResourceExtensions.WithIdTokenIssuance(null!));
Assert.Equal("builder", exception.ParamName);
}
[Theory]
[InlineData(DistributedApplicationOperation.Run)]
[InlineData(DistributedApplicationOperation.Publish)]
public async Task WithIdTokenIssuance_DoesNotProvisionExistingApplication(DistributedApplicationOperation operation)
{
using var builder = TestDistributedApplicationBuilder.Create(operation);
var entra = builder.AddExistingEntraApplication("entra-web", tenantId: TenantId, clientId: ClientId)
.WithIdTokenIssuance();
Assert.True(entra.Resource.IsExistingApplication);
Assert.Equal(TenantId, entra.Resource.TenantId);
Assert.Equal(ClientId, entra.Resource.ClientId);
Assert.Null(entra.Resource.ProvisioningTaskCompletionSource);
Assert.Empty(entra.Resource.Annotations.OfType<PipelineStepAnnotation>());
Assert.Empty(entra.Resource.Annotations.OfType<PipelineConfigurationAnnotation>());
Assert.Null(await ManifestUtils.GetManifestOrNull(entra.Resource));
}
[Fact]
public async Task AddExistingEntraApplication_DoesNotPublishOrAwaitProvisioning()
{
using var builder = TestDistributedApplicationBuilder.Create();
var entra = builder.AddExistingEntraApplication("entra-web", tenantId: TenantId, clientId: ClientId);
Assert.True(entra.Resource.IsExistingApplication);
Assert.Null(entra.Resource.ProvisioningTaskCompletionSource);
Assert.Empty(entra.Resource.Annotations.OfType<PipelineStepAnnotation>());
Assert.Empty(entra.Resource.Annotations.OfType<PipelineConfigurationAnnotation>());
Assert.Null(await ManifestUtils.GetManifestOrNull(entra.Resource));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task AsExistingApplication_PreservesAzureProvisioningSetup(bool useParameters)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var customization = new PipelineConfigurationAnnotation(_ => { });
var entra = builder.AddEntraIdApplication("entra-web")
.WithAnnotation(customization);
var existing = useParameters
? entra.AsExistingApplication(builder.AddParameter("tenant-id", TenantId), builder.AddParameter("client-id", ClientId))
: entra.AsExistingApplication(TenantId, ClientId);
Assert.Same(entra, existing);
Assert.True(existing.Resource.IsExistingApplication);
Assert.Null(existing.Resource.ProvisioningTaskCompletionSource);
Assert.Single(builder.Resources.OfType<AzureEnvironmentResource>());
Assert.Empty(existing.Resource.Annotations.OfType<PipelineStepAnnotation>());
Assert.Same(customization, Assert.Single(existing.Resource.Annotations.OfType<PipelineConfigurationAnnotation>()));
Assert.Null(await ManifestUtils.GetManifestOrNull(existing.Resource));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task AddExistingEntraApplication_DeployDoesNotRequireAzure(bool useParameters)
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
using var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish, outputHelper, workspace.Path, step: WellKnownPipelineSteps.Deploy);
var entra = useParameters
? builder.AddExistingEntraApplication("entra-web",
builder.AddParameter("tenant-id", TenantId), builder.AddParameter("client-id", ClientId))
: builder.AddExistingEntraApplication("entra-web", TenantId, ClientId);
Assert.True(entra.Resource.IsExistingApplication);
Assert.Empty(builder.Resources.OfType<AzureEnvironmentResource>());
Assert.All(builder.Services, service => Assert.NotEqual(typeof(ITokenCredentialProvider), service.ServiceType));
// Fail instead of acquiring real credentials if Azure authentication is accidentally activated.
builder.Services.AddSingleton<ITokenCredentialProvider>(_ => throw new InvalidOperationException("Azure authentication must not be used."));
var reporter = new TestPipelineActivityReporter(outputHelper);
builder.Services.AddSingleton<IPipelineActivityReporter>(reporter);
var deploymentExecuted = false;
builder.Pipeline.AddStep("verify-existing-registration", context =>
{
Assert.Empty(context.Model.Resources.OfType<AzureEnvironmentResource>());
Assert.All(context.PipelineContext.SelectedSteps, step =>
Assert.False(step.Name is "validate-azure-login" or "create-provisioning-context" or "provision-azure-bicep-resources"));
deploymentExecuted = true;
return Task.CompletedTask;
}, requiredBy: WellKnownPipelineSteps.Deploy);
using var app = builder.Build();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await app.RunAsync(cts.Token);
Assert.True(deploymentExecuted);
Assert.True(reporter.CompletePublishCalled);
Assert.NotEmpty(reporter.CompletedSteps);
Assert.All(reporter.CompletedSteps, step => Assert.Equal(CompletionState.Completed, step.CompletionState));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void AddExistingEntraApplication_PreservesAzureProvisioningForOtherResources(bool addAzureFirst)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var name = builder.AddParameter("arm-name", "existing-resource");
var group = builder.AddParameter("arm-group", "existing-resource-group");
if (addAzureFirst)
{
builder.AddAzureInfrastructure("other", _ => { }).AsExisting(name, group);
}
var existing = builder.AddExistingEntraApplication("entra-web", TenantId, ClientId);
if (!addAzureFirst)
{
builder.AddAzureInfrastructure("other", _ => { }).AsExisting(name, group);
}
Assert.Single(builder.Resources.OfType<AzureEnvironmentResource>());
Assert.Empty(existing.Resource.Annotations.OfType<PipelineStepAnnotation>());
var azure = Assert.Single(builder.Resources.OfType<AzureProvisioningResource>(), resource => resource.Name == "other");
Assert.Single(azure.Annotations.OfType<PipelineStepAnnotation>());
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void AddExistingEntraApplication_PreservesProvisionedEntraRegistrations(bool addCreatedFirst)
{
using var builder = TestDistributedApplicationBuilder.Create();
if (addCreatedFirst)
{
builder.AddEntraIdApplication("created");
}
builder.AddExistingEntraApplication("existing", TenantId, ClientId);
if (!addCreatedFirst)
{
builder.AddEntraIdApplication("created");
}
Assert.Single(builder.Resources.OfType<AzureEnvironmentResource>());
var created = Assert.Single(builder.Resources.OfType<EntraIdApplicationResource>(), resource => resource.Name == "created");
Assert.Single(created.Annotations.OfType<PipelineStepAnnotation>());
Assert.NotNull(created.ProvisioningTaskCompletionSource);
}
[Fact]
public void AddExistingEntraApplication_PreservesAppHostPipelineAnnotations()
{
using var builder = TestDistributedApplicationBuilder.Create();
var customization = new PipelineConfigurationAnnotation(_ =>
{
});
var entra = builder.AddExistingEntraApplication("entra-web", tenantId: TenantId, clientId: ClientId)
.WithAnnotation(customization);
Assert.Same(customization, Assert.Single(entra.Resource.Annotations.OfType<PipelineConfigurationAnnotation>()));
}
[Theory]
[InlineData(DistributedApplicationOperation.Run)]
[InlineData(DistributedApplicationOperation.Publish)]
public void AsExisting_RejectsArmResourceIdentity(DistributedApplicationOperation operation)
{
using var builder = TestDistributedApplicationBuilder.Create(operation);
var entra = builder.AddEntraIdApplication("entra-web")
.AsExisting(builder.AddParameter("application-name"), builder.AddParameter("resource-group"));
var exception = Assert.Throws<InvalidOperationException>(entra.Resource.GetBicepTemplateString);
Assert.Equal(
"Entra ID applications are identified by tenant ID and client ID, not by an Azure resource name or resource group. " +
"Use AddExistingEntraApplication(name, tenantId, clientId) to reference an existing registration without provisioning it.",
exception.Message);
}
[Fact]
public void GetBicepTemplate_RejectsInvalidSignInAudience()
{
using var builder = TestDistributedApplicationBuilder.Create();
var entra = builder.AddEntraIdApplication("entra-web");
entra.Resource.SignInAudience = (EntraIdSignInAudience)42;
var exception = Assert.Throws<InvalidOperationException>(entra.Resource.GetBicepTemplateString);
Assert.Equal("'42' is not a valid EntraIdSignInAudience value.", exception.Message);
}
[Theory]
[InlineData(null, null)]
[InlineData("ABC123", "CN=Contoso")]
public void GetBicepTemplate_RejectsInvalidCredentialBeforeProvisioning(string? thumbprint, string? distinguishedName)
{
using var builder = TestDistributedApplicationBuilder.Create();
var entra = builder.AddEntraIdApplication("entra-web")
.WithCredential(new EntraIdStoreCertificateCredential
{
StorePath = "CurrentUser/My",
Thumbprint = thumbprint,
DistinguishedName = distinguishedName
});
var exception = Assert.Throws<InvalidOperationException>(entra.Resource.GetBicepTemplateString);
Assert.Equal(thumbprint is null
? "Either Thumbprint or DistinguishedName must be set on EntraIdStoreCertificateCredential."
: "Only one of Thumbprint or DistinguishedName can be set on EntraIdStoreCertificateCredential.",
exception.Message);
}
[Theory]
[InlineData(DistributedApplicationOperation.Run, EntraIdSignInAudience.AzureADMyOrg, null)]
[InlineData(DistributedApplicationOperation.Publish, EntraIdSignInAudience.AzureADMyOrg, null)]
[InlineData(DistributedApplicationOperation.Run, EntraIdSignInAudience.AzureADMultipleOrgs, "organizations")]
[InlineData(DistributedApplicationOperation.Publish, EntraIdSignInAudience.AzureADMultipleOrgs, "organizations")]
[InlineData(DistributedApplicationOperation.Run, EntraIdSignInAudience.AzureADandPersonalMicrosoftAccount, "common")]
[InlineData(DistributedApplicationOperation.Publish, EntraIdSignInAudience.AzureADandPersonalMicrosoftAccount, "common")]
[InlineData(DistributedApplicationOperation.Run, EntraIdSignInAudience.PersonalMicrosoftAccount, "consumers")]
[InlineData(DistributedApplicationOperation.Publish, EntraIdSignInAudience.PersonalMicrosoftAccount, "consumers")]
public async Task WithReference_CreatedApplication_UsesOutputs(
DistributedApplicationOperation operation,
EntraIdSignInAudience audience,
string? signInTenant)
{
using var builder = TestDistributedApplicationBuilder.Create();
var entra = builder.AddEntraIdApplication("entra-web").WithSignInAudience(audience);
entra.Resource.Outputs["tenantId"] = TenantId;
entra.Resource.Outputs["clientId"] = ClientId;
entra.Resource.ProvisioningTaskCompletionSource!.SetResult();
var project = builder.AddResource(new ProjectResource("web")).WithReference(entra);
var container = builder.AddContainer("worker", "myimage").WithReference(entra, connectionName: "ENTRA");
var tenantId = operation == DistributedApplicationOperation.Run ? TenantId : "{entra-web.outputs.tenantId}";
var clientId = operation == DistributedApplicationOperation.Run ? ClientId : "{entra-web.outputs.clientId}";
var expectedProject = new Dictionary<string, string>
{
["AzureAd__Instance"] = "https://login.microsoftonline.com/",
["AzureAd__TenantId"] = signInTenant ?? tenantId,
["AzureAd__ClientId"] = clientId
};
var expectedContainer = new Dictionary<string, string>
{
["ENTRA_INSTANCE"] = "https://login.microsoftonline.com/",
["ENTRA_TENANT_ID"] = signInTenant ?? tenantId,
["ENTRA_CLIENT_ID"] = clientId
};
if (signInTenant is not null)
{
expectedProject["AzureAd__AppHomeTenantId"] = tenantId;
expectedContainer["ENTRA_APP_HOME_TENANT_ID"] = tenantId;
}
Assert.Equal(expectedProject, await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
project.Resource, operation, TestServiceProvider.Instance));
Assert.Equal(expectedContainer, await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
container.Resource, operation, TestServiceProvider.Instance));
}
[Fact]
public async Task WithReference_EnvironmentResolutionWaitsForProvisionedOutputs()
{
using var builder = TestDistributedApplicationBuilder.Create();
var entra = builder.AddEntraIdApplication("entra-web");
var node = builder.AddExecutable("node", "node", ".")
.WithReference(entra);
using var app = builder.Build();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var environmentTask = EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
node.Resource, DistributedApplicationOperation.Run, app.Services).AsTask();
Assert.False(environmentTask.IsCompleted);
entra.Resource.Outputs["tenantId"] = TenantId;
entra.Resource.Outputs["clientId"] = ClientId;
entra.Resource.ProvisioningTaskCompletionSource!.SetResult();
Assert.Equal(new Dictionary<string, string>
{
["ENTRA_WEB_INSTANCE"] = "https://login.microsoftonline.com/",
["ENTRA_WEB_TENANT_ID"] = TenantId,
["ENTRA_WEB_CLIENT_ID"] = ClientId
}, await environmentTask.WaitAsync(cts.Token));
}
[Theory]
[InlineData(nameof(ProjectResource), false)]
[InlineData(nameof(ExecutableResource), false)]
[InlineData(nameof(ContainerResource), false)]
[InlineData(nameof(ProjectResource), true)]
[InlineData(nameof(ExecutableResource), true)]
[InlineData(nameof(ContainerResource), true)]
public void WithReference_DoesNotAddImplicitWaits(string resourceType, bool existing)
{
using var builder = TestDistributedApplicationBuilder.Create();
var entra = existing
? builder.AddExistingEntraApplication("entra-web", tenantId: TenantId, clientId: ClientId)
: builder.AddEntraIdApplication("entra-web");
IResourceBuilder<IResourceWithEnvironment> consumer = resourceType switch
{
nameof(ProjectResource) => builder.AddResource(new ProjectResource("consumer")),
nameof(ExecutableResource) => builder.AddExecutable("consumer", "node", "."),
nameof(ContainerResource) => builder.AddContainer("consumer", "test-image"),
_ => throw new ArgumentOutOfRangeException(nameof(resourceType))
};
Assert.Same(consumer, consumer.WithReference(entra));
consumer.WithReference(entra, connectionName: "OTHER");
Assert.Empty(consumer.Resource.Annotations.OfType<WaitAnnotation>());
}
[Fact]
public void WithReference_PreservesExplicitWaitBehavior()
{
using var builder = TestDistributedApplicationBuilder.Create();
var entra = builder.AddEntraIdApplication("entra-web");
var node = builder.AddExecutable("node", "node", ".")
.WaitFor(entra, WaitBehavior.StopOnResourceUnavailable)
.WithReference(entra);
var wait = Assert.Single(node.Resource.Annotations.OfType<WaitAnnotation>());
Assert.Same(entra.Resource, wait.Resource);
Assert.Equal(WaitBehavior.StopOnResourceUnavailable, wait.WaitBehavior);
}
[Fact]
public async Task WithReference_EnvironmentResolutionPropagatesProvisioningFailure()
{
using var builder = TestDistributedApplicationBuilder.Create();
var entra = builder.AddEntraIdApplication("entra-web");
var node = builder.AddExecutable("node", "node", ".")
.WithReference(entra);
using var app = builder.Build();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var failure = new InvalidOperationException("Deployment failed.");
var environmentTask = EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
node.Resource, DistributedApplicationOperation.Run, app.Services).AsTask();
Assert.False(environmentTask.IsCompleted);
entra.Resource.ProvisioningTaskCompletionSource!.SetException(failure);
var exception = await Assert.ThrowsAsync<AggregateException>(() => environmentTask.WaitAsync(cts.Token));
Assert.Collection(exception.InnerExceptions,
error => Assert.Same(failure, error),
error => Assert.Same(failure, error));
}
[Fact]
public async Task ClientIdOutputReference_WaitsForProvisioning()
{
using var builder = TestDistributedApplicationBuilder.Create();
var entra = builder.AddEntraIdApplication("entra-web");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var value = entra.Resource.ClientIdOutputReference.GetValueAsync(cts.Token).AsTask();
Assert.False(value.IsCompleted);
entra.Resource.Outputs["clientId"] = ClientId;
entra.Resource.ProvisioningTaskCompletionSource!.SetResult();
Assert.Equal(ClientId, await value);
}
[Fact]
public async Task ClientIdOutputReference_CancelingAWaitDoesNotCancelProvisioning()
{
using var builder = TestDistributedApplicationBuilder.Create();
var entra = builder.AddEntraIdApplication("entra-web");
using var cts = new CancellationTokenSource();
var value = entra.Resource.ClientIdOutputReference.GetValueAsync(cts.Token).AsTask();
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => value);
Assert.False(entra.Resource.ProvisioningTaskCompletionSource!.Task.IsCompleted);
entra.Resource.Outputs["clientId"] = ClientId;
entra.Resource.ProvisioningTaskCompletionSource.SetResult();
Assert.Equal(ClientId, await entra.Resource.ClientIdOutputReference.GetValueAsync());
}
}