// 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
#pragma warning disable ASPIRECOMPUTE002
#pragma warning disable ASPIREPIPELINES001
#pragma warning disable AZPROVISION001
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Utils;
using Azure.Provisioning;
using Azure.Provisioning.ApiManagement;
using Azure.Provisioning.Network;
using Microsoft.Extensions.DependencyInjection;
using static Aspire.Hosting.Utils.AzureManifestUtils;
namespace Aspire.Hosting.Azure.Tests;
public class AzureApiManagementTests(ITestOutputHelper output)
{
[Fact]
public void AddAzureApiManagementCreatesResource()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
Assert.Equal("apim", apim.Resource.Name);
Assert.Equal("Contoso APIs", apim.Resource.Options.PublisherName);
Assert.Equal(AzureApiManagementSku.Developer, apim.Resource.Options.Sku);
Assert.Equal(1, apim.Resource.Options.Capacity);
Assert.Equal("gatewayUrl", apim.Resource.GatewayUrl.Name);
Assert.Equal("id", apim.Resource.Id.Name);
Assert.Equal("principalId", apim.Resource.PrincipalId.Name);
Assert.Same(apim.Resource.Identity, apim.Resource.PrincipalId.Resource);
Assert.Contains(apim.Resource.Identity, apim.Resource.References);
}
[Fact]
public void ApiManagementResourcesAreNotAddedInRunMode()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
var backend = builder.AddDotnetProject("backend", "project.csproj", options => options.ExcludeLaunchProfile = true)
.WithHttpsEndpoint()
.WithExternalHttpEndpoints();
var resources = builder.Resources.ToArray();
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var api = apim.AddApi("catalog-api", backend, "catalog");
var operation = api.AddOperation("get-product", "get", "/products/{id}");
Assert.Equal(resources, builder.Resources);
Assert.Single(apim.Resource.Apis);
Assert.Single(api.Resource.Operations);
Assert.Equal("get-product", operation.Resource.Name);
}
[Fact]
public void InternalContainerAppEnvironmentIsIgnoredInRunMode()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
var vnet = builder.AddAzureVirtualNetwork("vnet");
var subnet = vnet.AddSubnet("container-apps-subnet", "10.0.0.0/23");
var environment = builder.AddAzureContainerAppEnvironment("env")
.WithDelegatedSubnet(subnet)
.WithInternalLoadBalancer(vnet);
Assert.Equal("env", environment.Resource.Name);
Assert.Equal(["azure-environment"], builder.Resources.Select(resource => resource.Name));
}
[Theory]
[InlineData(AzureApiManagementSku.Consumption, 1)]
[InlineData(AzureApiManagementSku.Developer, 0)]
[InlineData(AzureApiManagementSku.Basic, 3)]
[InlineData(AzureApiManagementSku.BasicV2, 11)]
[InlineData(AzureApiManagementSku.Standard, 5)]
[InlineData(AzureApiManagementSku.StandardV2, 11)]
[InlineData(AzureApiManagementSku.Premium, 13)]
[InlineData(AzureApiManagementSku.PremiumV2, 31)]
public void AddAzureApiManagementRejectsInvalidCapacity(AzureApiManagementSku sku, int capacity)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var exception = Assert.Throws<ArgumentException>(() =>
builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = sku,
Capacity = capacity,
}));
Assert.Equal("capacity", exception.ParamName);
}
[Fact]
public void ConsumptionSkuDefaultsToZeroCapacity()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.Consumption,
});
Assert.Equal(0, apim.Resource.Options.Capacity);
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void AddAzureApiManagementRejectsMissingPublisherName(string? publisherName)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var exception = Assert.ThrowsAny<ArgumentException>(() =>
builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = publisherName!,
}));
Assert.Equal("options.PublisherName", exception.ParamName);
}
[Fact]
public void AddAzureApiManagementRejectsPublisherFieldsOverMaximumLength()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var emailException = Assert.Throws<ArgumentException>(() =>
builder.AddAzureApiManagement("email-apim", new()
{
PublisherEmail = new string('a', 101),
PublisherName = "Contoso APIs",
}));
var nameException = Assert.Throws<ArgumentException>(() =>
builder.AddAzureApiManagement("name-apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = new string('a', 101),
}));
Assert.Contains("publisher email address", emailException.Message);
Assert.Contains("publisher name", nameException.Message);
}
[Fact]
public void AddApiAndOperationCreateParentedResources()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var backend = builder.AddDotnetProject("backend", "project.csproj", options => options.ExcludeLaunchProfile = true)
.WithHttpsEndpoint()
.WithExternalHttpEndpoints();
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var api = apim.AddApi("catalog-api", backend, "/catalog/", "Catalog", subscriptionRequired: true);
var operation = api.AddOperation("get-product", "get", "/products/{id}", "Get product");
Assert.Equal("catalog", api.Resource.Path);
Assert.Equal("Catalog", api.Resource.DisplayName);
Assert.True(api.Resource.SubscriptionRequired);
Assert.Same(apim.Resource, api.Resource.Parent);
Assert.Equal("GET", operation.Resource.Method);
Assert.Same(api.Resource, operation.Resource.Parent);
Assert.Contains(api.Resource, builder.Resources);
Assert.Contains(operation.Resource, builder.Resources);
}
[Fact]
public void ApiAndOperationSupportDistinctPhysicalNamesAndSecureDefaults()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var backend = builder.AddDotnetProject("backend", "project.csproj", options => options.ExcludeLaunchProfile = true)
.WithHttpsEndpoint()
.WithExternalHttpEndpoints();
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var api = apim.AddApi("catalog-api", backend, "catalog", apiName: "physical-api");
var operation = api.AddOperation(
"get-product",
"get",
"/products/{id}",
operationName: "physical-operation");
Assert.Equal("physical-api", api.Resource.ApiName);
Assert.True(api.Resource.SubscriptionRequired);
Assert.Equal("physical-operation", operation.Resource.OperationName);
}
[Fact]
public void ApiAndOperationValidatePhysicalIdentifierConstraints()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var backend = builder.AddDotnetProject("backend", "project.csproj", options => options.ExcludeLaunchProfile = true)
.WithHttpsEndpoint()
.WithExternalHttpEndpoints();
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var longApiName = new string('a', 256);
var api = apim.AddApi("catalog-api", backend, "catalog", apiName: longApiName);
Assert.Equal(longApiName, api.Resource.ApiName);
Assert.Throws<ArgumentException>(() =>
apim.AddApi("invalid-api", backend, "invalid", apiName: "invalid?api"));
Assert.Throws<ArgumentException>(() =>
api.AddOperation("invalid-operation", "GET", "/", operationName: new string('o', 81)));
}
[Fact]
public void ApiAndOperationValidatePropertyLengthConstraints()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var backend = builder.AddDotnetProject("backend", "project.csproj", options => options.ExcludeLaunchProfile = true)
.WithHttpsEndpoint()
.WithExternalHttpEndpoints();
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var api = apim.AddApi("catalog-api", backend, "catalog");
Assert.Throws<ArgumentException>(() =>
apim.AddApi("long-path-api", backend, new string('p', 401)));
Assert.Throws<ArgumentException>(() =>
apim.AddApi("long-display-api", backend, "long-display", new string('d', 301)));
Assert.Throws<ArgumentException>(() =>
apim.AddApi("blank-display-api", backend, "blank-display", " "));
Assert.Throws<ArgumentException>(() =>
api.AddOperation("long-template", "GET", new string('u', 1001)));
Assert.Throws<ArgumentException>(() =>
api.AddOperation("long-display", "GET", "/", new string('d', 301)));
Assert.Throws<ArgumentException>(() =>
api.AddOperation("blank-display", "GET", "/", " "));
}
[Fact]
public void ApiPhysicalIdentifiersAndPathsMustBeUnique()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var backend = builder.AddDotnetProject("backend", "project.csproj", options => options.ExcludeLaunchProfile = true)
.WithHttpsEndpoint()
.WithExternalHttpEndpoints();
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
apim.AddApi("catalog-api", backend, "/catalog/", apiName: "physical-api");
var duplicateName = Assert.Throws<InvalidOperationException>(
() => apim.AddOpenAIApi("openai-api", "openai", apiName: "PHYSICAL-API"));
var duplicatePath = Assert.Throws<InvalidOperationException>(
() => apim.AddOpenAIApi("other-api", "CATALOG"));
Assert.Contains("physical identifier 'PHYSICAL-API'", duplicateName.Message);
Assert.Contains("path 'CATALOG'", duplicatePath.Message);
Assert.Single(apim.Resource.Apis);
}
[Fact]
public async Task ComputeBackendPhysicalNamesMustNotConflictWithDeclaredBackends()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var environment = builder.AddAzureContainerAppEnvironment("env");
var target = builder.AddDotnetProject("backend", "project.csproj", options => options.ExcludeLaunchProfile = true)
.WithHttpsEndpoint()
.WithComputeEnvironment(environment)
.WithExternalHttpEndpoints();
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
apim.AddBackend("declared-backend", ReferenceExpression.Create($"https://example.com"), backendName: "catalog_apiBackend");
apim.AddApi("catalog-api", target, "catalog");
using var app = builder.Build();
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => ExecuteBeforeStartHooksAsync(app, default));
Assert.Contains("generates backend physical name 'catalog_apiBackend'", exception.Message);
}
[Fact]
public void OperationPhysicalIdentifiersMustBeUniqueAndCannotUseGeneratedProxyNames()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var backend = builder.AddDotnetProject("backend", "project.csproj", options => options.ExcludeLaunchProfile = true)
.WithHttpsEndpoint()
.WithExternalHttpEndpoints();
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var api = apim.AddApi("catalog-api", backend, "catalog");
api.AddOperation("get-product", "GET", "/products/{id}", operationName: "physical-operation");
var duplicateName = Assert.Throws<InvalidOperationException>(
() => api.AddOperation("get-other-product", "GET", "/products/other", operationName: "PHYSICAL-OPERATION"));
var reservedName = Assert.Throws<ArgumentException>(
() => api.AddOperation("custom-proxy", "GET", "/proxy", operationName: "PrOxY"));
var reservedMethodName = Assert.Throws<ArgumentException>(
() => api.AddOperation("custom-proxy-get", "GET", "/proxy", operationName: "PrOxY-GeT"));
var wildcardMethod = Assert.Throws<ArgumentException>(
() => api.AddOperation("wildcard-operation", "*", "/{*path}"));
Assert.Contains("physical identifier 'PHYSICAL-OPERATION'", duplicateName.Message);
Assert.Contains("'PrOxY' is reserved", reservedName.Message);
Assert.Contains("'PrOxY-GeT' is reserved", reservedMethodName.Message);
Assert.Contains("wildcard HTTP method", wildcardMethod.Message);
Assert.Single(api.Resource.Operations);
}
[Theory]
[InlineData("/products/{id}", "/products/{productId}")]
[InlineData("/products/{id}", "/products/{id}")]
[InlineData("/products/{id}/{*path}", "/products/{key}/{*rest}")]
[InlineData("/products/{id}?expand={fields}&version={v}", "/products/{key}?version={ver}&expand={selection}")]
public void OperationRoutesRejectEquivalentSignatures(string first, string second)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new() { PublisherEmail = "api-owners@example.com", PublisherName = "Contoso APIs" });
var api = apim.AddApi("catalog-api", "catalog");
api.AddOperation("first", "GET", first);
var resourceCount = builder.Resources.Count;
var exception = Assert.Throws<InvalidOperationException>(() => api.AddOperation("second", "get", second));
Assert.Contains("HTTP method and URL template must be unique", exception.Message);
Assert.Single(api.Resource.Operations);
Assert.Equal(resourceCount, builder.Resources.Count);
}
[Theory]
[InlineData("GET", "/{*other}")]
[InlineData("post", "/{*path}")]
[InlineData("GET", "/")]
public async Task OperationRoutesRejectGeneratedProxyConflicts(string method, string template)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new() { PublisherEmail = "api-owners@example.com", PublisherName = "Contoso APIs" });
var backend = apim.AddBackend("backend", ReferenceExpression.Create($"https://example.com"));
var api = apim.AddApi("catalog-api", "catalog").WithBackend(backend);
api.AddOperation("custom", method, template);
// The conflict is checked against the API's final configuration during infrastructure
// generation, not at AddOperation call time, so that call order relative to
// WithOpenApiDocument does not change the validation outcome.
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => GetManifestWithBicep(apim.Resource));
Assert.Contains("conflicts with a generated proxy", exception.Message);
}
[Theory]
[InlineData("GET", "/{*other}")]
[InlineData("post", "/{*path}")]
[InlineData("GET", "/")]
public async Task OperationRoutesRejectGeneratedProxyConflictsRegardlessOfAddOperationCallOrder(string method, string template)
{
using var temporaryWorkspace = TemporaryWorkspace.Create(output);
var documentPath = Path.Combine(temporaryWorkspace.Path, "catalog.json");
File.WriteAllText(documentPath, """{"openapi":"3.0.1","info":{"title":"Catalog","version":"v1"},"paths":{}}""");
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new() { PublisherEmail = "api-owners@example.com", PublisherName = "Contoso APIs" });
var backend = apim.AddBackend("backend", ReferenceExpression.Create($"https://example.com"));
var api = apim.AddApi("catalog-api", "catalog").WithBackend(backend);
api.AddOperation("custom", method, template);
api.WithOpenApiDocument(documentPath);
// Once an OpenAPI import is configured, no proxy routes are generated, so the same
// route that previously conflicted must now be allowed, regardless of whether
// WithOpenApiDocument was called before or after AddOperation.
var (_, bicep) = await GetManifestWithBicep(apim.Resource);
Assert.Single(api.Resource.Operations);
}
[Fact]
public void OperationRoutesAllowDifferentMethodsLiteralsAndApis()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new() { PublisherEmail = "api-owners@example.com", PublisherName = "Contoso APIs" });
var api = apim.AddApi("catalog-api", "catalog");
api.AddOperation("get-product", "GET", "/products/{id}");
api.AddOperation("post-product", "POST", "/products/{id}");
api.AddOperation("get-category", "GET", "/categories/{id}");
apim.AddApi("other-api", "other").AddOperation("other-product", "GET", "/products/{id}");
Assert.Equal(3, api.Resource.Operations.Count);
Assert.Single(apim.Resource.Apis[1].Operations);
}
[Fact]
public void ImportedApiAllowsExplicitRootAndWildcardRoutes()
{
using var temporaryWorkspace = TemporaryWorkspace.Create(output);
var documentPath = Path.Combine(temporaryWorkspace.Path, "catalog.json");
File.WriteAllText(documentPath, """{"openapi":"3.0.1","info":{"title":"Catalog","version":"v1"},"paths":{}}""");
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new() { PublisherEmail = "api-owners@example.com", PublisherName = "Contoso APIs" });
var api = apim.AddApi("catalog-api", "catalog").WithOpenApiDocument(documentPath);
api.AddOperation("root", "GET", "/");
api.AddOperation("wildcard", "GET", "/{*path}");
Assert.Equal(2, api.Resource.Operations.Count);
}
[Fact]
public void PolicyHelpersValidateAndPreserveScopeSemantics()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var backend = builder.AddDotnetProject("backend", "project.csproj", options => options.ExcludeLaunchProfile = true)
.WithHttpsEndpoint()
.WithExternalHttpEndpoints();
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var api = apim.AddApi("catalog-api", backend, "catalog");
var operation = api.AddOperation("get-products", "get", "/products");
apim.WithInboundPolicy("<set-header name=\"x-service\" exists-action=\"override\"><value>service</value></set-header>");
api.WithInboundPolicy("<rate-limit calls=\"10\" renewal-period=\"60\" />");
operation.WithPolicy("<policies><inbound><base /></inbound><backend><base /></backend><outbound><base /></outbound><on-error><base /></on-error></policies>");
Assert.Single(apim.Resource.InboundPolicyStatements);
Assert.Single(api.Resource.InboundPolicyStatements);
Assert.NotNull(operation.Resource.PolicyXml);
Assert.Throws<ArgumentException>(() => apim.WithInboundPolicy("<policies />"));
Assert.Throws<ArgumentException>(() => api.WithPolicy("<rate-limit calls=\"10\" renewal-period=\"60\" />"));
Assert.Throws<InvalidOperationException>(() => operation.WithInboundPolicy("<rate-limit calls=\"10\" renewal-period=\"60\" />"));
Assert.Throws<InvalidOperationException>(() => apim.WithPolicy("<policies><inbound /></policies>"));
}
[Fact]
public void AzureApiManagementResourceImplementsPrivateEndpointTarget()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var target = Assert.IsAssignableFrom<IAzurePrivateEndpointTarget>(apim.Resource);
Assert.Equal(["Gateway"], target.GetPrivateLinkGroupIds());
Assert.Equal(["privatelink.azure-api.net"], target.GetPrivateDnsZoneNames());
}
[Fact]
public void WithClassicVirtualNetworkRejectsUnsupportedSku()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var subnet = builder.AddAzureVirtualNetwork("vnet")
.AddSubnet("apim-subnet", "10.0.0.0/24");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.StandardV2,
});
Assert.Throws<InvalidOperationException>(() =>
apim.WithClassicVirtualNetwork(subnet, AzureApiManagementVirtualNetworkMode.External));
}
[Fact]
public void WithClassicVirtualNetworkRejectsDelegatedSubnet()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var subnet = builder.AddAzureVirtualNetwork("vnet")
.AddSubnet("apim-subnet", "10.0.0.0/24")
.WithServiceDelegation(AzureSubnetServiceDelegations.ContainerAppEnvironments);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
Assert.Throws<InvalidOperationException>(() =>
apim.WithClassicVirtualNetwork(subnet, AzureApiManagementVirtualNetworkMode.External));
}
[Fact]
public async Task PrivateEndpointsPreservePublicAccess()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var vnet = builder.AddAzureVirtualNetwork("vnet");
var firstPrivateEndpointSubnet = vnet.AddSubnet("private-endpoint-subnet-1", "10.0.1.0/24");
var secondPrivateEndpointSubnet = vnet.AddSubnet("private-endpoint-subnet-2", "10.0.2.0/24");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
firstPrivateEndpointSubnet.AddPrivateEndpoint(apim);
secondPrivateEndpointSubnet.AddPrivateEndpoint(apim);
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var modules = new List<string>();
foreach (var resource in builder.Resources.OfType<AzureProvisioningResource>())
{
var (_, bicep) = await GetManifestWithBicep(resource);
modules.Add($"// Resource: {resource.Name}\n{bicep}");
}
await Verify(string.Join("\n\n", modules), "bicep");
}
[Fact]
public async Task PrivateEndpointRejectsUnsupportedSku()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var subnet = builder.AddAzureVirtualNetwork("vnet")
.AddSubnet("private-endpoint-subnet", "10.0.0.0/24");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.Consumption,
Capacity = 0,
});
subnet.AddPrivateEndpoint(apim);
using var app = builder.Build();
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => ExecuteBeforeStartHooksAsync(app, default));
Assert.Contains("does not support private endpoints", exception.Message);
}
[Fact]
public async Task PrivateEndpointSupportsExistingServiceWithoutMutation()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var subnet = builder.AddAzureVirtualNetwork("vnet")
.AddSubnet("private-endpoint-subnet", "10.0.0.0/24");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.Developer,
}).PublishAsExisting("existing-apim", resourceGroup: null);
var privateEndpoint = subnet.AddPrivateEndpoint(apim);
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var (_, bicep) = await GetManifestWithBicep(apim.Resource);
var (_, privateEndpointBicep) = await GetManifestWithBicep(privateEndpoint.Resource);
await Verify($"{bicep}\n{privateEndpointBicep}", "bicep");
}
[Fact]
public async Task PrivateEndpointRejectsVirtualNetworkInjection()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var virtualNetwork = builder.AddAzureVirtualNetwork("vnet");
var injectionSubnet = virtualNetwork.AddSubnet("apim-subnet", "10.0.0.0/24");
var privateEndpointSubnet = virtualNetwork.AddSubnet("private-endpoint-subnet", "10.0.1.0/24");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.PremiumV2,
}).WithVirtualNetworkInjection(injectionSubnet);
privateEndpointSubnet.AddPrivateEndpoint(apim);
using var app = builder.Build();
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => ExecuteBeforeStartHooksAsync(app, default));
Assert.Contains("cannot combine a private endpoint with virtual network injection", exception.Message);
}
[Fact]
public async Task StandardV2VirtualNetworkIntegrationGeneratesDelegatedSubnet()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var virtualNetwork = builder.AddAzureVirtualNetwork("vnet");
var subnet = virtualNetwork.AddSubnet("apim-subnet", "10.0.0.0/24");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.StandardV2,
}).WithVirtualNetworkIntegration(subnet);
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var (_, apimBicep) = await GetManifestWithBicep(apim.Resource);
var (_, virtualNetworkBicep) = await GetManifestWithBicep(virtualNetwork.Resource);
var (_, networkSecurityGroupBicep) = await GetManifestWithBicep(subnet.Resource.NetworkSecurityGroup!);
Assert.Contains("virtualNetworkType: 'External'", apimBicep);
Assert.Contains("Microsoft.Web/serverFarms", virtualNetworkBicep);
Assert.Contains("destinationAddressPrefix: 'AzureKeyVault'", networkSecurityGroupBicep);
Assert.Contains("destinationPortRange: '443'", networkSecurityGroupBicep);
await Verify(apimBicep, "bicep");
}
[Fact]
public async Task PremiumV2VirtualNetworkInjectionGeneratesPrivateService()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var virtualNetwork = builder.AddAzureVirtualNetwork("vnet");
var subnet = virtualNetwork.AddSubnet("apim-subnet", "10.0.0.0/24");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.PremiumV2,
}).WithVirtualNetworkInjection(subnet);
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var (_, apimBicep) = await GetManifestWithBicep(apim.Resource);
var (_, virtualNetworkBicep) = await GetManifestWithBicep(virtualNetwork.Resource);
var (_, networkSecurityGroupBicep) = await GetManifestWithBicep(subnet.Resource.NetworkSecurityGroup!);
Assert.Contains("virtualNetworkType: 'Internal'", apimBicep);
Assert.DoesNotContain("publicNetworkAccess:", apimBicep);
Assert.Contains("Microsoft.Web/hostingEnvironments", virtualNetworkBicep);
Assert.Contains("destinationAddressPrefix: 'AzureKeyVault'", networkSecurityGroupBicep);
Assert.Contains("destinationPortRange: '443'", networkSecurityGroupBicep);
await Verify(apimBicep, "bicep");
}
[Theory]
[InlineData(AzureApiManagementSku.StandardV2, "microsoft.web/serverfarms")]
[InlineData(AzureApiManagementSku.PremiumV2, "MICROSOFT.WEB/HOSTINGENVIRONMENTS")]
public void V2VirtualNetworkAcceptsCaseInsensitiveDelegation(AzureApiManagementSku sku, string delegation)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var subnet = builder.AddAzureVirtualNetwork("vnet").AddSubnet("subnet", "10.0.0.0/24")
.WithServiceDelegation(delegation);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = sku,
});
if (sku == AzureApiManagementSku.StandardV2)
{
apim.WithVirtualNetworkIntegration(subnet);
}
else
{
apim.WithVirtualNetworkInjection(subnet);
}
Assert.Equal(delegation, subnet.Resource.Annotations.OfType<AzureSubnetServiceDelegationAnnotation>().Single().ServiceName);
Assert.NotNull(apim.Resource.VirtualNetworkConfiguration);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task V2KeyVaultAllowPrecedesExistingOutboundDeny(bool injection)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var subnet = builder.AddAzureVirtualNetwork("vnet").AddSubnet("subnet", "10.0.0.0/24")
.AllowInbound(port: "443", priority: 100)
.DenyOutbound(priority: 200);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = injection ? AzureApiManagementSku.PremiumV2 : AzureApiManagementSku.StandardV2,
});
if (injection)
{
apim.WithVirtualNetworkInjection(subnet).WithVirtualNetworkInjection(subnet);
}
else
{
apim.WithVirtualNetworkIntegration(subnet).WithVirtualNetworkIntegration(subnet);
}
var rules = subnet.Resource.NetworkSecurityGroup!.SecurityRules;
Assert.Equal(101, rules.Single(rule => rule.Name == "allow-apim-key-vault").Priority);
Assert.Equal(3, rules.Count);
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var (_, bicep) = await GetManifestWithBicep(subnet.Resource.NetworkSecurityGroup);
await Verify(bicep, "bicep").UseParameters(injection);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void V2KeyVaultAllowRejectsUnavoidableOutboundDeny(bool injection)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var subnet = builder.AddAzureVirtualNetwork("vnet").AddSubnet("subnet", "10.0.0.0/24")
.DenyOutbound(port: "400-500", priority: 100);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = injection ? AzureApiManagementSku.PremiumV2 : AzureApiManagementSku.StandardV2,
});
var exception = Assert.Throws<InvalidOperationException>(() =>
{
if (injection)
{
apim.WithVirtualNetworkInjection(subnet);
}
else
{
apim.WithVirtualNetworkIntegration(subnet);
}
});
Assert.Contains("no free NSG priority", exception.Message);
Assert.Null(apim.Resource.VirtualNetworkConfiguration);
Assert.Single(subnet.Resource.NetworkSecurityGroup!.SecurityRules);
}
[Fact]
public async Task V2KeyVaultAllowRejectsLaterShadowingRule()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var subnet = builder.AddAzureVirtualNetwork("vnet").AddSubnet("subnet", "10.0.0.0/24")
.AllowInbound(port: "443", priority: 100);
builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.StandardV2,
}).WithVirtualNetworkIntegration(subnet);
var firstRule = subnet.Resource.NetworkSecurityGroup!.SecurityRules[0];
firstRule.Direction = SecurityRuleDirection.Outbound;
firstRule.Access = SecurityRuleAccess.Deny;
using var app = builder.Build();
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => ExecuteBeforeStartHooksAsync(app, default));
Assert.Contains("must retain an effective", exception.Message);
}
[Fact]
public void V2VirtualNetworkRejectsSubnetSmallerThanSlash27()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var subnet = builder.AddAzureVirtualNetwork("vnet")
.AddSubnet("apim-subnet", "10.0.0.0/28");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.StandardV2,
});
var exception = Assert.Throws<InvalidOperationException>(() => apim.WithVirtualNetworkIntegration(subnet));
Assert.Contains("requires a /27 or larger subnet", exception.Message);
}
[Fact]
public void V2VirtualNetworkRejectsSubnetUsedByAnotherApiManagementService()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var subnet = builder.AddAzureVirtualNetwork("vnet")
.AddSubnet("apim-subnet", "10.0.0.0/24");
builder.AddAzureApiManagement("first-apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.StandardV2,
}).WithVirtualNetworkIntegration(subnet);
var second = builder.AddAzureApiManagement("second-apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.StandardV2,
});
var exception = Assert.Throws<InvalidOperationException>(() => second.WithVirtualNetworkIntegration(subnet));
Assert.Contains("already used by API Management resource 'first-apim'", exception.Message);
}
[Fact]
public void VirtualNetworkRejectsSubnetUsedByClassicApiManagementService()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var subnet = builder.AddAzureVirtualNetwork("vnet")
.AddSubnet("apim-subnet", "10.0.0.0/24");
builder.AddAzureApiManagement("classic-apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
}).WithClassicVirtualNetwork(subnet, AzureApiManagementVirtualNetworkMode.External);
var second = builder.AddAzureApiManagement("v2-apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.StandardV2,
});
var exception = Assert.Throws<InvalidOperationException>(() => second.WithVirtualNetworkIntegration(subnet));
Assert.Contains("already used by API Management resource 'classic-apim'", exception.Message);
}
[Fact]
public async Task AddAzureApiManagementWithApiAndPoliciesGeneratesBicep()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var environment = builder.AddAzureContainerAppEnvironment("env");
var backend = builder.AddDotnetProject("catalog-backend", "project.csproj", options => options.ExcludeLaunchProfile = true)
.WithHttpsEndpoint()
.WithComputeEnvironment(environment)
.WithExternalHttpEndpoints();
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.StandardV2,
Capacity = 2,
}).WithInboundPolicy(
"<set-header name=\"x-gateway\" exists-action=\"override\"><value>apim</value></set-header>");
var api = apim.AddApi("catalog-api", backend, "/catalog", "Catalog API", subscriptionRequired: true)
.WithInboundPolicy("<rate-limit calls=\"100\" renewal-period=\"60\" />");
api.AddOperation("get-product", "get", "/products/{id}/{*path}", "Get product")
.WithInboundPolicy("<set-query-parameter name=\"source\" exists-action=\"override\"><value>apim</value></set-query-parameter>");
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var (_, bicep) = await GetManifestWithBicep(apim.Resource);
var proxyMethods = new[] { "DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "TRACE" };
foreach (var proxyMethod in proxyMethods)
{
Assert.Contains($"name: 'proxy-{proxyMethod.ToLowerInvariant()}'", bicep);
}
Assert.Equal(proxyMethods.Length, bicep.Split("urlTemplate: '/{*path}'").Length - 1);
Assert.DoesNotContain("method: '*'", bicep);
await Verify(bicep, "bicep");
}
[Fact]
public async Task OpenApiDocumentImportGeneratesApiImport()
{
using var temporaryWorkspace = TemporaryWorkspace.Create(output);
var documentPath = Path.Combine(temporaryWorkspace.Path, "catalog.json");
await File.WriteAllTextAsync(
documentPath,
"""{"openapi":"3.0.1","info":{"title":"Catalog","version":"v1"},"paths":{}}""",
TestContext.Current.CancellationToken);
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var backend = apim.AddBackend("catalog-backend", ReferenceExpression.Create($"https://example.com"));
apim.AddApi("catalog-api", "catalog")
.WithBackend(backend)
.WithOpenApiDocument(documentPath);
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var (_, bicep) = await GetManifestWithBicep(apim.Resource);
Assert.Contains("format: 'openapi+json'", bicep);
Assert.Contains("""value: '{"openapi":"3.0.1","info":{"title":"Catalog","version":"v1"},"paths":{}}'""", bicep);
Assert.DoesNotContain("name: 'proxy'", bicep);
await Verify(bicep, "bicep");
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void RunModeDoesNotReadOpenApiDocument(bool emptyFile)
{
using var workspace = TemporaryWorkspace.Create(output);
var documentPath = Path.Combine(workspace.Path, "deploy-only.json");
if (emptyFile)
{
File.WriteAllText(documentPath, " ");
}
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
var apim = builder.AddAzureApiManagement("apim", new() { PublisherEmail = "api-owners@example.com", PublisherName = "Contoso APIs" });
var api = apim.AddApi("catalog-api", "catalog").WithOpenApiDocument(documentPath);
api.AddOperation("root", "GET", "/");
var source = Assert.IsType<AzureApiManagementOpenApiFile>(api.Resource.OpenApiSource);
Assert.Equal(documentPath, source.Path);
Assert.Equal(AzureApiManagementOpenApiFormat.OpenApiJson, source.Format);
Assert.Empty(builder.Resources.OfType<AzureApiManagementResource>());
using var app = builder.Build();
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task PublishRejectsMissingOrEmptyOpenApiDocument(bool emptyFile)
{
using var workspace = TemporaryWorkspace.Create(output);
var documentPath = Path.Combine(workspace.Path, "deploy-only.json");
if (emptyFile)
{
File.WriteAllText(documentPath, " ");
}
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new() { PublisherEmail = "api-owners@example.com", PublisherName = "Contoso APIs" });
var backend = apim.AddBackend("backend", ReferenceExpression.Create($"https://example.com"));
apim.AddApi("catalog-api", "catalog").WithBackend(backend).WithOpenApiDocument(documentPath);
using var app = builder.Build();
if (emptyFile)
{
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => ExecuteBeforeStartHooksAsync(app, default));
Assert.Contains("is empty", exception.Message);
}
else
{
var exception = await Assert.ThrowsAsync<FileNotFoundException>(() => ExecuteBeforeStartHooksAsync(app, default));
Assert.Equal(documentPath, exception.FileName);
}
}
[Fact]
public async Task OpenApiDocumentCanBeCreatedAfterModelConfiguration()
{
using var workspace = TemporaryWorkspace.Create(output);
var documentPath = Path.Combine(workspace.Path, "generated.json");
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new() { PublisherEmail = "api-owners@example.com", PublisherName = "Contoso APIs" });
var backend = apim.AddBackend("backend", ReferenceExpression.Create($"https://example.com"));
apim.AddApi("catalog-api", "catalog").WithBackend(backend).WithOpenApiDocument(documentPath);
const string document = """{"openapi":"3.0.1","info":{"title":"Catalog","version":"v1"},"paths":{}}""";
File.WriteAllText(documentPath, document);
string? importedDocument = null;
apim.ConfigureInfrastructure(infrastructure =>
{
importedDocument = infrastructure.GetProvisionableResources()
.OfType<ApiManagementApi>().Single().Value.Value;
});
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
Assert.Equal(document, importedDocument);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task ApiPolicyWaitsForOperationConfiguration(bool importOpenApi)
{
using var temporaryWorkspace = TemporaryWorkspace.Create(output);
var documentPath = Path.Combine(temporaryWorkspace.Path, "catalog.json");
await File.WriteAllTextAsync(
documentPath,
"""{"openapi":"3.0.1","info":{"title":"Catalog","version":"v1"},"paths":{}}""",
TestContext.Current.CancellationToken);
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var backend = apim.AddBackend("catalog-backend", ReferenceExpression.Create($"https://example.com"));
var api = apim.AddApi("catalog-api", "catalog").WithBackend(backend);
if (importOpenApi)
{
api.WithOpenApiDocument(documentPath)
.WithPolicy("<policies><inbound><base /><set-backend-service backend-id=\"catalog-backend\" /></inbound></policies>");
}
api.AddOperation("get-product", "GET", "/products/{id}");
api.AddOperation("list-products", "GET", "/products")
.WithInboundPolicy("<set-header name=\"x-source\" exists-action=\"override\"><value>apim</value></set-header>");
// A different API's operations must not become dependencies of the catalog policy.
apim.AddApi("other-api", "other").WithBackend(backend)
.AddOperation("other-operation", "GET", "/other");
string[]? dependencies = null;
apim.ConfigureInfrastructure(infrastructure =>
{
var policy = infrastructure.GetProvisionableResources()
.OfType<ApiPolicy>()
.Single(resource => resource.BicepIdentifier == "_apim_apiPolicy_catalog_api");
dependencies = policy.DependsOn.Select(resource => resource.BicepIdentifier).ToArray();
});
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var (_, bicep) = await GetManifestWithBicep(apim.Resource);
var expected = new List<string> { "catalog_backend" };
if (!importOpenApi)
{
expected.AddRange(new[] { "DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "TRACE" }
.SelectMany(method => new[]
{
$"_apim_proxy{method}RootOperation_catalog_api",
$"_apim_proxy{method}Operation_catalog_api",
}));
}
expected.Add("get_product");
expected.Add("_apim_operationPolicy_list_products");
Assert.Equal(expected, dependencies);
await Verify(bicep, "bicep").UseParameters(importOpenApi);
}
[Fact]
public async Task OpenApiEndpointImportUsesPublishedComputeEndpoint()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var environment = builder.AddAzureContainerAppEnvironment("env");
var backend = builder.AddDotnetProject("catalog-backend", "project.csproj", options => options.ExcludeLaunchProfile = true)
.WithHttpsEndpoint()
.WithComputeEnvironment(environment)
.WithExternalHttpEndpoints();
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
apim.AddApi("catalog-api", backend, "catalog")
.WithOpenApiEndpoint("/openapi/v1.json");
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var (manifest, bicep) = await GetManifestWithBicep(apim.Resource);
Assert.Contains("format: 'openapi+json-link'", bicep);
Assert.Contains("param _apim_openApiUrl_catalog_api string", bicep);
Assert.Contains("param _apim_computeBackendUrl_catalog_api string", bicep);
Assert.Contains(
"url: empty(catalog_api.properties.?serviceUrl) ? _apim_computeBackendUrl_catalog_api : catalog_api.properties.?serviceUrl",
bicep);
Assert.DoesNotContain("name: 'proxy'", bicep);
Assert.Contains("catalog-backend", manifest.ToJsonString());
Assert.Contains("/openapi/v1.json", manifest.ToJsonString());
await Verify(bicep, "bicep");
}
[Fact]
public async Task OpenApiDocumentDoesNotRequireEndpointDeploymentDependency()
{
using var temporaryWorkspace = TemporaryWorkspace.Create(output);
var documentPath = Path.Combine(temporaryWorkspace.Path, "catalog.json");
await File.WriteAllTextAsync(
documentPath,
"""{"openapi":"3.0.1","info":{"title":"Catalog","version":"v1"},"paths":{}}""",
TestContext.Current.CancellationToken);
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var environment = builder.AddAzureContainerAppEnvironment("env");
var backend = builder.AddDotnetProject("catalog-backend", "project.csproj", options => options.ExcludeLaunchProfile = true)
.WithHttpsEndpoint()
.WithComputeEnvironment(environment)
.WithExternalHttpEndpoints();
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
apim.AddApi("catalog-api", backend, "catalog")
.WithOpenApiDocument(documentPath);
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var provisionStep = new PipelineStep
{
Name = "provision-apim",
Action = static _ => Task.CompletedTask,
Resource = apim.Resource,
Tags = [WellKnownPipelineTags.ProvisionInfrastructure],
};
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var context = new PipelineConfigurationContext
{
Services = app.Services,
Model = model,
Steps = [provisionStep],
};
foreach (var annotation in apim.Resource.Annotations.OfType<PipelineConfigurationAnnotation>())
{
await annotation.Callback(context);
}
Assert.Empty(provisionStep.DependsOnSteps);
}
[Theory]
[InlineData(DistributedApplicationOperation.Run, false, false)]
[InlineData(DistributedApplicationOperation.Run, false, true)]
[InlineData(DistributedApplicationOperation.Run, true, false)]
[InlineData(DistributedApplicationOperation.Run, true, true)]
[InlineData(DistributedApplicationOperation.Publish, false, false)]
[InlineData(DistributedApplicationOperation.Publish, false, true)]
[InlineData(DistributedApplicationOperation.Publish, true, false)]
[InlineData(DistributedApplicationOperation.Publish, true, true)]
public void OpenApiRejectsConflictingSources(
DistributedApplicationOperation operation,
bool firstSourceIsFile,
bool secondSourceIsFile)
{
using var builder = TestDistributedApplicationBuilder.Create(operation);
var target = builder.AddDotnetProject("catalog-backend", "project.csproj", options => options.ExcludeLaunchProfile = true);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var api = apim.AddApi("catalog-api", target, "catalog");
if (firstSourceIsFile)
{
api.WithOpenApiDocument("first.json");
}
else
{
api.WithOpenApiEndpoint("/openapi/first.json");
}
var originalSource = api.Resource.OpenApiSource;
var exception = Assert.Throws<InvalidOperationException>(() =>
{
if (secondSourceIsFile)
{
api.WithOpenApiDocument("second.json");
}
else
{
api.WithOpenApiEndpoint("/openapi/second.json");
}
});
Assert.Equal(
"API Management API 'catalog-api' already has a different OpenAPI source. " +
"An API can use only one OpenAPI source, either a document file or an endpoint.",
exception.Message);
Assert.Same(originalSource, api.Resource.OpenApiSource);
}
[Fact]
public void OpenApiDocumentAllowsIdenticalSourceButRejectsDifferentFormat()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var api = apim.AddApi("catalog-api", "catalog").WithOpenApiDocument("catalog.json");
var originalSource = api.Resource.OpenApiSource;
Assert.Same(api, api.WithOpenApiDocument(
Path.Combine(builder.AppHostDirectory, "catalog.json"),
AzureApiManagementOpenApiFormat.OpenApiJson));
Assert.Equal(originalSource, api.Resource.OpenApiSource);
Assert.Throws<InvalidOperationException>(
() => api.WithOpenApiDocument("catalog.json", AzureApiManagementOpenApiFormat.SwaggerJson));
Assert.Equal(originalSource, api.Resource.OpenApiSource);
}
[Theory]
[InlineData("/openapi/other.json", null, AzureApiManagementOpenApiFormat.OpenApiJson)]
[InlineData("/openapi/v1.json", "https", AzureApiManagementOpenApiFormat.OpenApiJson)]
[InlineData("/openapi/v1.json", null, AzureApiManagementOpenApiFormat.SwaggerJson)]
public void OpenApiEndpointAllowsIdenticalSourceButRejectsDifferentConfiguration(
string documentPath,
string? endpointName,
AzureApiManagementOpenApiFormat format)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var target = builder.AddDotnetProject("catalog-backend", "project.csproj", options => options.ExcludeLaunchProfile = true);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var api = apim.AddApi("catalog-api", target, "catalog").WithOpenApiEndpoint();
var originalSource = api.Resource.OpenApiSource;
Assert.Same(api, api.WithOpenApiEndpoint(
"/openapi/v1.json", format: AzureApiManagementOpenApiFormat.OpenApiJson));
Assert.Equal(originalSource, api.Resource.OpenApiSource);
Assert.Throws<InvalidOperationException>(
() => api.WithOpenApiEndpoint(documentPath, endpointName, format));
Assert.Equal(originalSource, api.Resource.OpenApiSource);
}
[Fact]
public async Task OpenApiEndpointImportRejectsInternalContainerAppEnvironment()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var virtualNetwork = builder.AddAzureVirtualNetwork("vnet");
var containerAppsSubnet = virtualNetwork.AddSubnet("container-apps-subnet", "10.0.0.0/23");
var apimSubnet = virtualNetwork.AddSubnet("apim-subnet", "10.0.2.0/24");
var environment = builder.AddAzureContainerAppEnvironment("env")
.WithDelegatedSubnet(containerAppsSubnet)
.WithInternalLoadBalancer(virtualNetwork);
var backend = builder.AddDotnetProject("catalog-backend", "project.csproj", options => options.ExcludeLaunchProfile = true)
.WithHttpsEndpoint()
.WithComputeEnvironment(environment)
.WithExternalHttpEndpoints();
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.Premium,
}).WithClassicVirtualNetwork(apimSubnet, AzureApiManagementVirtualNetworkMode.External);
apim.AddApi("catalog-api", backend, "catalog")
.WithOpenApiEndpoint();
using var app = builder.Build();
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => ExecuteBeforeStartHooksAsync(app, default));
Assert.Contains("control plane cannot retrieve its OpenAPI document", exception.Message);
}
[Fact]
public async Task AddAzureApiManagementWithServiceFeaturesGeneratesBicep()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var environment = builder.AddAzureContainerAppEnvironment("env");
var backend = builder.AddDotnetProject("catalog-backend", "project.csproj", options => options.ExcludeLaunchProfile = true)
.WithHttpsEndpoint()
.WithComputeEnvironment(environment)
.WithExternalHttpEndpoints();
var insights = builder.AddAzureApplicationInsights("insights");
var vault = builder.AddAzureKeyVault("vault");
var apiKey = builder.AddParameter("api-key", secret: true);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.StandardV2,
});
var fragment = apim.AddPolicyFragment(
"correlation",
"<set-header name=\"x-correlation-id\" exists-action=\"skip\"><value>@(context.RequestId.ToString())</value></set-header>",
"Adds a correlation ID.");
apim.WithInboundPolicyFragment(fragment)
.WithApplicationInsights(insights, new()
{
SamplingPercentage = 25,
Verbosity = AzureApiManagementDiagnosticVerbosity.Error,
})
.WithCustomDomain(
"api.contoso.example",
vault.GetSecret("gateway-certificate"),
defaultSslBinding: true);
apim.AddNamedValue("backend-region", "westus3", tags: ["routing"]);
apim.AddSecretNamedValue("api-key-value", apiKey, displayName: "ApiKey");
apim.AddKeyVaultNamedValue(
"upstream-secret",
vault.GetSecret("upstream-secret"),
displayName: "UpstreamSecret");
var api = apim.AddApi("catalog-api", backend, "catalog")
.WithInboundPolicyFragment(fragment)
.WithApplicationInsights(insights, new()
{
SamplingPercentage = 50,
LogClientIp = true,
});
api.AddOperation("get-product", "GET", "/products/{id}")
.WithInboundPolicyFragment(fragment);
apim.AddProduct(
"catalog-product",
"Catalog",
new()
{
Description = "Catalog APIs",
Terms = "Use responsibly.",
})
.WithApi(api)
.AddSubscription("catalog-client", "Catalog client");
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var (_, bicep) = await GetManifestWithBicep(apim.Resource);
Assert.Contains("param _apim_namedValueParameter_api_key_value string", bicep);
await Verify(bicep, "bicep");
}
[Fact]
public async Task ProductApiAssociationsHaveUnambiguousBicepIdentifiers()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var environment = builder.AddAzureContainerAppEnvironment("env");
var backend = builder.AddDotnetProject("backend", "project.csproj", options => options.ExcludeLaunchProfile = true)
.WithHttpsEndpoint()
.WithComputeEnvironment(environment)
.WithExternalHttpEndpoints();
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var catalogApi = apim.AddApi("catalog-api", backend, "catalog");
var productCatalogApi = apim.AddApi("product-catalog-api", backend, "product-catalog");
apim.AddProduct("catalog-product", "Catalog product").WithApi(catalogApi);
apim.AddProduct("catalog", "Catalog").WithApi(productCatalogApi);
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var (_, bicep) = await GetManifestWithBicep(apim.Resource);
var associationDeclarations = bicep
.Split('\n')
.Where(line => line.StartsWith("resource _apim_productApi_", StringComparison.Ordinal))
.ToArray();
Assert.Equal(2, associationDeclarations.Length);
Assert.Equal(2, associationDeclarations.Distinct(StringComparer.Ordinal).Count());
}
[Fact]
public void ApiManagementServiceFeaturesValidateInvalidConfigurations()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var backend = builder.AddDotnetProject("backend", "project.csproj", options => options.ExcludeLaunchProfile = true)
.WithHttpsEndpoint()
.WithExternalHttpEndpoints();
var nonSecretParameter = builder.AddParameter("value");
var vault = builder.AddAzureKeyVault("vault");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var otherApim = builder.AddAzureApiManagement("other-apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var api = apim.AddApi("catalog-api", backend, "catalog");
var otherApi = otherApim.AddApi("other-api", backend, "other");
var product = apim.AddProduct("catalog-product", "Catalog");
var fragment = apim.AddPolicyFragment("shared-policy", "<rate-limit calls=\"10\" renewal-period=\"60\" />");
var otherFragment = otherApim.AddPolicyFragment("other-policy", "<rate-limit calls=\"20\" renewal-period=\"60\" />");
Assert.Throws<InvalidOperationException>(() => product.WithApi(otherApi));
Assert.Throws<InvalidOperationException>(() => api.WithInboundPolicyFragment(otherFragment));
Assert.Throws<ArgumentException>(() => apim.AddPolicyFragment("invalid-fragment", "<base />", fragmentName: "-invalid"));
Assert.Throws<ArgumentException>(() => apim.AddSecretNamedValue("secret", nonSecretParameter));
Assert.Throws<ArgumentException>(() => apim.AddNamedValue("invalid-value", "value", displayName: "invalid value"));
Assert.Throws<ArgumentException>(() => apim.AddNamedValue("empty-value", " "));
Assert.Throws<ArgumentException>(() => apim.AddNamedValue("long-value", new string('v', 4097)));
Assert.Throws<ArgumentException>(() => apim.AddNamedValue("empty-display-name", "value", displayName: ""));
Assert.Throws<ArgumentException>(() =>
apim.WithCustomDomain(
"portal.contoso.example",
vault.GetSecret("certificate"),
AzureApiManagementHostnameType.DeveloperPortal,
defaultSslBinding: true));
apim.WithCustomDomain(
"api.contoso.example",
vault.GetSecret("certificate"),
defaultSslBinding: true);
Assert.Throws<InvalidOperationException>(() =>
apim.WithCustomDomain(
"api2.contoso.example",
vault.GetSecret("certificate-2"),
defaultSslBinding: true));
api.WithInboundPolicyFragment(fragment);
Assert.Single(api.Resource.InboundPolicyStatements);
}
[Fact]
public void NamedValueDisplayNamesMustBeUnique()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
apim.AddNamedValue("first-value", "value", displayName: "shared-name");
var exception = Assert.Throws<InvalidOperationException>(
() => apim.AddNamedValue("second-value", "value", displayName: "SHARED-NAME"));
Assert.Contains("display name 'SHARED-NAME'", exception.Message);
}
[Theory]
[InlineData("gateway")]
[InlineData("gateway.")]
[InlineData("localhost")]
[InlineData("127.0.0.1")]
[InlineData("::1")]
[InlineData("https://api.example.com")]
[InlineData("api..example.com")]
public void CustomDomainsRejectUnqualifiedOrInvalidHostnames(string hostname)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var vault = builder.AddAzureKeyVault("vault");
var apim = builder.AddAzureApiManagement("apim", new() { PublisherEmail = "api-owners@example.com", PublisherName = "Contoso APIs" });
var exception = Assert.Throws<ArgumentException>(() => apim.WithCustomDomain(hostname, vault.GetSecret("certificate")));
Assert.Equal("hostname", exception.ParamName);
Assert.Empty(apim.Resource.CustomDomains);
}
[Theory]
[InlineData("api.example.com")]
[InlineData("API.EXAMPLE.COM")]
[InlineData("api.example.com.")]
[InlineData("api.example")]
public void CustomDomainsAcceptQualifiedDnsNames(string hostname)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var vault = builder.AddAzureKeyVault("vault");
var apim = builder.AddAzureApiManagement("apim", new() { PublisherEmail = "api-owners@example.com", PublisherName = "Contoso APIs" });
apim.WithCustomDomain(hostname, vault.GetSecret("certificate"));
Assert.Equal(hostname, Assert.Single(apim.Resource.CustomDomains).Hostname);
}
[Fact]
public void ConsumptionSkuSupportsOneGatewayCustomDomain()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var vault = builder.AddAzureKeyVault("vault");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.Consumption,
Capacity = 0,
});
apim.WithCustomDomain("api.contoso.example", vault.GetSecret("certificate"));
Assert.Single(apim.Resource.CustomDomains);
var exception = Assert.Throws<InvalidOperationException>(() =>
apim.WithCustomDomain(
"portal.contoso.example",
vault.GetSecret("portal-certificate"),
AzureApiManagementHostnameType.DeveloperPortal));
Assert.Contains("supports custom domains only for the gateway endpoint", exception.Message);
}
[Theory]
[InlineData(AzureApiManagementSku.BasicV2)]
[InlineData(AzureApiManagementSku.StandardV2)]
[InlineData(AzureApiManagementSku.PremiumV2)]
public void V2SkusRejectManagementAndScmCustomDomains(AzureApiManagementSku sku)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var vault = builder.AddAzureKeyVault("vault");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = sku,
});
Assert.Throws<InvalidOperationException>(() =>
apim.WithCustomDomain(
"management.contoso.example",
vault.GetSecret("management-certificate"),
AzureApiManagementHostnameType.Management));
Assert.Throws<InvalidOperationException>(() =>
apim.WithCustomDomain(
"scm.contoso.example",
vault.GetSecret("scm-certificate"),
AzureApiManagementHostnameType.Scm));
}
[Theory]
[InlineData(AzureApiManagementSku.Consumption)]
[InlineData(AzureApiManagementSku.Basic)]
[InlineData(AzureApiManagementSku.BasicV2)]
[InlineData(AzureApiManagementSku.Standard)]
[InlineData(AzureApiManagementSku.StandardV2)]
public void NonPremiumProductionSkusRejectMultipleGatewayCustomDomains(AzureApiManagementSku sku)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var vault = builder.AddAzureKeyVault("vault");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = sku,
});
apim.WithCustomDomain("api.contoso.example", vault.GetSecret("certificate"));
var exception = Assert.Throws<InvalidOperationException>(() =>
apim.WithCustomDomain("api2.contoso.example", vault.GetSecret("certificate-2")));
Assert.Contains("Multiple gateway custom domains", exception.Message);
}
[Theory]
[InlineData(AzureApiManagementSku.Developer)]
[InlineData(AzureApiManagementSku.Premium)]
[InlineData(AzureApiManagementSku.PremiumV2)]
public void SupportedSkusAllowMultipleGatewayCustomDomains(AzureApiManagementSku sku)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var vault = builder.AddAzureKeyVault("vault");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = sku,
});
apim.WithCustomDomain("api.contoso.example", vault.GetSecret("certificate"));
apim.WithCustomDomain("api2.contoso.example", vault.GetSecret("certificate-2"));
Assert.Equal(2, apim.Resource.CustomDomains.Count);
}
[Theory]
[InlineData(AzureApiManagementHostnameType.ConfigurationApi)]
[InlineData(AzureApiManagementHostnameType.DeveloperPortal)]
[InlineData(AzureApiManagementHostnameType.Portal)]
[InlineData(AzureApiManagementHostnameType.Management)]
[InlineData(AzureApiManagementHostnameType.Scm)]
public void NonGatewayEndpointsRejectMultipleCustomDomains(AzureApiManagementHostnameType type)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var vault = builder.AddAzureKeyVault("vault");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.Developer,
});
apim.WithCustomDomain(
"endpoint.contoso.example",
vault.GetSecret("certificate"),
type);
var exception = Assert.Throws<InvalidOperationException>(() =>
apim.WithCustomDomain(
"endpoint2.contoso.example",
vault.GetSecret("certificate-2"),
type));
Assert.Contains($"Only one custom hostname can be configured for endpoint type '{type}'", exception.Message);
}
[Fact]
public void ProductRejectsSubscriptionLimitWhenSubscriptionsAreDisabled()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var exception = Assert.Throws<ArgumentException>(() =>
apim.AddProduct(
"product",
"Product",
new()
{
SubscriptionRequired = false,
SubscriptionsLimit = 1,
}));
Assert.Contains("only be configured when subscriptions are required", exception.Message);
}
[Fact]
public async Task AddAzureApiManagementWithInternalContainerAppBackendGeneratesBicep()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var vnet = builder.AddAzureVirtualNetwork("vnet");
var containerAppsSubnet = vnet.AddSubnet("container-apps-subnet", "10.0.0.0/23");
var apimSubnet = vnet.AddSubnet("apim-subnet", "10.0.2.0/24");
var environment = builder.AddAzureContainerAppEnvironment("env")
.WithDelegatedSubnet(containerAppsSubnet)
.WithInternalLoadBalancer(vnet);
var backend = builder.AddDotnetProject("catalog-backend", "project.csproj", options => options.ExcludeLaunchProfile = true)
.WithHttpsEndpoint()
.WithComputeEnvironment(environment);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.Premium,
}).WithClassicVirtualNetwork(apimSubnet, AzureApiManagementVirtualNetworkMode.External);
apim.AddApi("catalog-api", backend, "catalog");
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var (manifest, bicep) = await GetManifestWithBicep(apim.Resource);
Assert.Contains("param _apim_computeBackendUrl_catalog_api string", bicep);
Assert.Contains("catalog-backend.internal", manifest.ToJsonString());
}
[Fact]
public async Task InternalBackendRequiresApiManagementVirtualNetworkConnectivity()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var environment = builder.AddAzureContainerAppEnvironment("env");
var backend = builder.AddDotnetProject("catalog-backend", "project.csproj", options => options.ExcludeLaunchProfile = true)
.WithHttpsEndpoint()
.WithComputeEnvironment(environment);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
apim.AddApi("catalog-api", backend, "catalog");
using var app = builder.Build();
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => ExecuteBeforeStartHooksAsync(app, default));
Assert.Contains("exposes only internal HTTP endpoints", exception.Message);
}
[Fact]
public async Task AddAzureApiManagementWithClassicVirtualNetworkGeneratesBicep()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var subnet = builder.AddAzureVirtualNetwork("vnet")
.AddSubnet("apim-subnet", "10.0.0.0/24");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.Premium,
Capacity = 2,
}).WithClassicVirtualNetwork(subnet, AzureApiManagementVirtualNetworkMode.Internal);
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var (_, bicep) = await GetManifestWithBicep(apim.Resource);
await Verify(bicep, "bicep");
}
[Fact]
public async Task AddOpenAIApiWithFoundryBackendsGeneratesBicep()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var primaryFoundry = builder.AddFoundry("foundry-primary");
var primary = primaryFoundry.AddDeployment("chat-primary", "gpt-5-mini", "2025-08-07", "OpenAI");
var secondaryFoundry = builder.AddFoundry("foundry-secondary");
var secondary = secondaryFoundry.AddDeployment("chat-secondary", "gpt-5-mini", "2025-08-07", "OpenAI");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.StandardV2,
});
var primaryBackend = apim.AddFoundryBackend("chat-primary-backend", primary);
var secondaryBackend = apim.AddFoundryBackend("chat-secondary-backend", secondary);
var pool = apim.AddBackendPool("openai-pool")
.WithBackend(primaryBackend, priority: 1, weight: 3)
.WithBackend(secondaryBackend, priority: 2, weight: 1);
apim.AddOpenAIApi("openai-api", "openai")
.WithBackend(pool);
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var (manifest, bicep) = await GetManifestWithBicep(apim.Resource);
Assert.Contains("openai/deployments/chat-primary", manifest.ToJsonString());
Assert.Contains("openai/deployments/chat-secondary", manifest.ToJsonString());
Assert.Contains("foundry-primary.outputs.endpoint", manifest.ToJsonString());
await Verify(bicep, "bicep");
}
[Fact]
public void ConsumptionSkuRejectsOpenAIBackendPools()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.Consumption,
Capacity = 0,
});
var exception = Assert.Throws<InvalidOperationException>(() => apim.AddBackend(
"backend",
ReferenceExpression.Create($"https://example.com"),
new AzureApiManagementBackendOptions
{
CircuitBreaker = new AzureApiManagementCircuitBreakerOptions(),
}));
Assert.Contains("not supported by the Consumption SKU", exception.Message);
}
[Fact]
public void OpenAIBackendPoolAcceptsZeroPriorityAndWeight()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var foundry = builder.AddFoundry("foundry");
var model = foundry.AddDeployment("chat", "gpt-5-mini", "2025-08-07", "OpenAI");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var backend = apim.AddFoundryBackend("chat-backend", model);
var pool = apim.AddBackendPool("openai-pool")
.WithBackend(backend, priority: 0, weight: 0);
var member = Assert.Single(pool.Resource.Backends);
Assert.Equal(0, member.Priority);
Assert.Equal(0, member.Weight);
}
[Fact]
public async Task AzureOpenAIBackendUsesAccountEndpointAndDeduplicatesRoleAssignments()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var account = builder.AddAzureOpenAI("openai");
var deployment = account.AddDeployment("chat", "gpt-5-mini", "2025-08-07");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var backend = apim.AddAzureOpenAIBackend("openai-backend", deployment);
apim.AddOpenAIApi("chat-api", "chat")
.WithBackend(backend);
apim.AddOpenAIApi("responses-api", "responses")
.WithBackend(backend);
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var (manifest, bicep) = await GetManifestWithBicep(apim.Resource);
var manifestJson = manifest.ToJsonString();
Assert.Contains("openai.outputs.endpoint", manifestJson);
Assert.Contains("openai/deployments/chat", manifestJson);
var roles = Assert.Single(builder.Resources.OfType<AzureRoleAssignmentResource>());
Assert.Same(apim.Resource.Identity, roles.IdentityResource);
var (_, rolesBicep) = await GetManifestWithBicep(roles, skipPreparer: true);
Assert.Single(rolesBicep.Split("Microsoft.Authorization/roleAssignments@", StringSplitOptions.None).Skip(1));
Assert.Contains("5e0bd9bd-7b93-4f28-af87-19fc36ad61bd", rolesBicep);
await Verify(bicep, "bicep");
}
[Theory]
[InlineData(false, "ai-resources")]
[InlineData(true, null)]
[InlineData(true, "ignored-resources")]
public async Task CrossResourceGroupAzureOpenAIBackendUsesTargetScopedRoleAssignmentModule(
bool overrideScope,
string? resourceGroup)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var account = builder.AddAzureOpenAI("openai")
.PublishAsExisting("shared-openai", resourceGroup: resourceGroup);
if (overrideScope)
{
account.Resource.Scope = new AzureBicepResourceScope("ai-resources");
}
var deployment = account.AddDeployment("chat", "gpt-5-mini", "2025-08-07");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var chatBackend = apim.AddAzureOpenAIBackend("chat-backend", deployment);
var responsesBackend = apim.AddAzureOpenAIBackend("responses-backend", deployment);
apim.AddOpenAIApi("chat-api", "chat").WithBackend(chatBackend);
apim.AddOpenAIApi("responses-api", "responses").WithBackend(responsesBackend);
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var roleAssignments = Assert.Single(
model.Resources.OfType<AzureRoleAssignmentResource>(),
resource => resource.Name == "apim-identity-roles-openai");
Assert.Same(account.Resource, roleAssignments.TargetAzureResource);
Assert.Equal("ai-resources", roleAssignments.Scope!.ResourceGroup);
Assert.Same(apim.Resource.Identity, roleAssignments.OwnerResource);
Assert.Same(apim.Resource.Identity, roleAssignments.IdentityResource);
Assert.Contains(roleAssignments, apim.Resource.References);
var (_, apimBicep) = await GetManifestWithBicep(apim.Resource);
var (rolesManifest, rolesBicep) = await GetManifestWithBicep(roleAssignments, skipPreparer: true);
Assert.DoesNotContain("Microsoft.Authorization/roleAssignments@", apimBicep);
Assert.Equal(1, rolesBicep.Split("Microsoft.Authorization/roleAssignments@", StringSplitOptions.None).Length - 1);
Assert.Contains("resource openai", rolesBicep);
Assert.DoesNotContain("scope: resourceGroup('ai-resources')", rolesBicep);
Assert.Contains("ai-resources", rolesManifest.ToJsonString());
Assert.Contains("apim-identity.outputs.principalId", rolesManifest.ToJsonString());
}
[Fact]
public async Task UserAssignedIdentityPermissionsCoverBackendsAndKeyVault()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var openai = builder.AddAzureOpenAI("openai");
var openaiDeployment = openai.AddDeployment("chat", "gpt-5-mini", "2025-08-07");
var foundry = builder.AddFoundry("foundry");
var foundryDeployment = foundry.AddDeployment("foundry-chat", "gpt-5-mini", "2025-08-07", "OpenAI");
var blobs = builder.AddAzureStorage("storage").AddBlobs("blobs");
var vault = builder.AddAzureKeyVault("vault");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
apim.AddAzureOpenAIBackend("openai-backend", openaiDeployment);
apim.AddFoundryBackend("foundry-backend", foundryDeployment);
apim.AddBlobStorageBackend("blob-backend", blobs);
apim.WithCustomDomain("api.contoso.example", vault.GetSecret("gateway-certificate"));
apim.AddKeyVaultNamedValue("upstream-secret", vault.GetSecret("upstream-secret"));
apim.AddKeyVaultNamedValue("another-secret", vault.GetSecret("another-secret"));
await VerifyAllAzureBicep(builder);
Assert.Same(apim.Resource.Identity, Assert.Single(builder.Resources.OfType<AzureUserAssignedIdentityResource>()));
var roles = builder.Resources.OfType<AzureRoleAssignmentResource>().ToArray();
Assert.Equal(4, roles.Length);
Assert.All(roles, role =>
{
Assert.Same(apim.Resource.Identity, role.IdentityResource);
Assert.Contains(role, apim.Resource.References);
});
}
[Fact]
public async Task BackendRolesArePreparedByStandardAzureResourcePreparer()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var account = builder.AddAzureOpenAI("openai")
.PublishAsExisting("shared-openai", resourceGroup: "ai-resources");
var deployment = account.AddDeployment("chat", "gpt-5-mini", "2025-08-07");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
apim.AddAzureOpenAIBackend("openai-backend", deployment);
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var roles = Assert.Single(builder.Resources.OfType<AzureRoleAssignmentResource>());
Assert.Same(account.Resource, roles.TargetAzureResource);
Assert.Same(apim.Resource.Identity, roles.IdentityResource);
Assert.Same(apim.Resource.Identity, roles.OwnerResource);
Assert.Contains(roles, apim.Resource.References);
var (manifest, bicep) = await GetManifestWithBicep(roles, skipPreparer: true);
await Verify(new { Manifest = manifest.ToJsonString(), Bicep = bicep });
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task ExplicitlyScopedExistingApiManagementUsesExternalRoleModuleForDefaultScopeBackend(bool overrideScope)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var account = builder.AddAzureOpenAI("openai")
.PublishAsExisting("shared-openai", resourceGroup: null);
var deployment = account.AddDeployment("chat", "gpt-5-mini", "2025-08-07");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.Developer,
}).PublishAsExisting("shared-apim", resourceGroup: overrideScope ? null : "gateway-resources");
var identity = builder.AddAzureUserAssignedIdentity("shared-identity")
.PublishAsExisting("shared-identity", resourceGroup: null);
apim.WithAzureUserAssignedIdentity(identity);
if (overrideScope)
{
apim.Resource.Scope = new AzureBicepResourceScope("gateway-resources");
}
var backend = apim.AddAzureOpenAIBackend("openai-backend", deployment);
apim.AddOpenAIApi("chat-api", "chat").WithBackend(backend);
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var roleAssignments = Assert.Single(
model.Resources.OfType<AzureRoleAssignmentResource>(),
resource => resource.Name == "shared-identity-roles-openai");
Assert.Null(roleAssignments.Scope);
Assert.Same(account.Resource, roleAssignments.TargetAzureResource);
Assert.Same(apim.Resource.Identity, roleAssignments.IdentityResource);
Assert.Contains(roleAssignments, apim.Resource.References);
var (_, apimBicep) = await GetManifestWithBicep(apim.Resource, skipPreparer: true);
var (rolesManifest, rolesBicep) = await GetManifestWithBicep(roleAssignments, skipPreparer: true);
Assert.DoesNotContain("Microsoft.Authorization/roleAssignments@", apimBicep);
Assert.Contains("Microsoft.Authorization/roleAssignments@", rolesBicep);
Assert.DoesNotContain("\"scope\"", rolesManifest.ToJsonString());
Assert.Contains("shared-identity.outputs.principalId", rolesManifest.ToJsonString());
}
[Fact]
public async Task SameScopeExistingAzureOpenAIBackendUsesStandardRoleAssignmentModule()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var account = builder.AddAzureOpenAI("openai")
.PublishAsExisting("shared-openai", resourceGroup: null);
var deployment = account.AddDeployment("chat", "gpt-5-mini", "2025-08-07");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var backend = apim.AddAzureOpenAIBackend("openai-backend", deployment);
apim.AddOpenAIApi("chat-api", "chat").WithBackend(backend);
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var (_, apimBicep) = await GetManifestWithBicep(apim.Resource);
var roles = Assert.Single(model.Resources.OfType<AzureRoleAssignmentResource>());
Assert.Same(apim.Resource.Identity, roles.IdentityResource);
Assert.Null(roles.Scope);
var (_, rolesBicep) = await GetManifestWithBicep(roles, skipPreparer: true);
await Verify(new { ApiManagement = apimBicep, Roles = rolesBicep });
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task CrossResourceGroupKeyVaultUsesStandaloneIdentityAndTargetScopedRoleAssignments(bool overrideScope)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var vault = builder.AddAzureKeyVault("vault")
.PublishAsExisting("shared-vault", resourceGroup: overrideScope ? null : "security-resources");
if (overrideScope)
{
vault.Resource.Scope = new AzureBicepResourceScope("security-resources");
}
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
apim.WithCustomDomain("api.contoso.example", vault.GetSecret("gateway-certificate"));
apim.AddKeyVaultNamedValue("upstream-secret", vault.GetSecret("upstream-secret"));
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var identity = Assert.Single(
model.Resources.OfType<AzureUserAssignedIdentityResource>(),
resource => resource.Name == "apim-identity");
var roleAssignments = Assert.Single(
model.Resources.OfType<AzureRoleAssignmentResource>(),
resource => resource.Name == "apim-identity-roles-vault");
Assert.Same(identity, apim.Resource.Identity);
Assert.Same(vault.Resource, roleAssignments.TargetAzureResource);
Assert.Equal("security-resources", roleAssignments.Scope!.ResourceGroup);
Assert.Contains(identity, apim.Resource.References);
Assert.Contains(roleAssignments, apim.Resource.References);
Assert.Contains(
identity.Annotations.OfType<ResourceRelationshipAnnotation>(),
relationship => relationship.Resource == apim.Resource && relationship.Type == "Parent");
Assert.Same(identity, roleAssignments.IdentityResource);
var (_, apimBicep) = await GetManifestWithBicep(apim.Resource);
var (rolesManifest, rolesBicep) = await GetManifestWithBicep(roleAssignments, skipPreparer: true);
Assert.DoesNotContain("resource _apim_keyVaultIdentity_", apimBicep);
Assert.DoesNotContain("Microsoft.Authorization/roleAssignments@", apimBicep);
Assert.Contains("param apim_identity_outputs_id string", apimBicep);
Assert.Equal(2, rolesBicep.Split("Microsoft.Authorization/roleAssignments@", StringSplitOptions.None).Length - 1);
Assert.DoesNotContain("scope: resourceGroup('security-resources')", rolesBicep);
Assert.Contains("security-resources", rolesManifest.ToJsonString());
Assert.Contains("apim-identity.outputs.principalId", rolesManifest.ToJsonString());
}
[Fact]
public async Task MultipleApiManagementServicesUseIndependentStandardIdentities()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var account = builder.AddAzureOpenAI("openai")
.PublishAsExisting("shared-openai", resourceGroup: "ai-resources");
var deployment = account.AddDeployment("chat", "gpt-5-mini", "2025-08-07");
var vault = builder.AddAzureKeyVault("vault")
.PublishAsExisting("shared-vault", resourceGroup: "security-resources");
var services = new List<IResourceBuilder<AzureApiManagementResource>>();
for (var index = 0; index < 2; index++)
{
var apim = builder.AddAzureApiManagement($"apim-{index}", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
apim.AddAzureOpenAIBackend($"openai-backend-{index}", deployment);
apim.AddKeyVaultNamedValue($"upstream-secret-{index}", vault.GetSecret("upstream-secret"));
services.Add(apim);
}
using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
await ExecuteBeforeStartHooksAsync(app, default);
Assert.Equal(
[
"apim-0-identity-roles-openai",
"apim-0-identity-roles-vault",
"apim-1-identity-roles-openai",
"apim-1-identity-roles-vault",
],
model.Resources.OfType<AzureRoleAssignmentResource>().Select(resource => resource.Name).Order());
Assert.Equal(
["apim-0-identity", "apim-1-identity"],
model.Resources.OfType<AzureUserAssignedIdentityResource>().Select(resource => resource.Name).Order());
}
[Fact]
public async Task PublishedMainBicepOrdersKeyVaultRoleAssignmentsBeforeApiManagement()
{
using var workspace = TemporaryWorkspace.Create(output);
using var builder = TestDistributedApplicationBuilder.Create(
DistributedApplicationOperation.Publish,
workspace.Path);
var vault = builder.AddAzureKeyVault("vault")
.PublishAsExisting("shared-vault", resourceGroup: "security-resources");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
apim.AddKeyVaultNamedValue("upstream-secret", vault.GetSecret("upstream-secret"));
using var app = builder.Build();
await app.RunAsync(TestContext.Current.CancellationToken);
var mainBicepPath = Path.Combine(workspace.Path, "main.bicep");
Assert.True(File.Exists(mainBicepPath), $"Expected publish to produce '{mainBicepPath}'.");
var mainBicep = await File.ReadAllTextAsync(
mainBicepPath,
TestContext.Current.CancellationToken);
await Verify(mainBicep, "bicep");
}
[Fact]
public async Task SameScopeExistingKeyVaultUsesStandardIdentityAndRoleAssignments()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var vault = builder.AddAzureKeyVault("vault")
.PublishAsExisting("shared-vault", resourceGroup: null);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
apim.AddKeyVaultNamedValue("upstream-secret", vault.GetSecret("upstream-secret"));
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var (_, apimBicep) = await GetManifestWithBicep(apim.Resource);
Assert.Same(apim.Resource.Identity, Assert.Single(model.Resources.OfType<AzureUserAssignedIdentityResource>()));
var roles = Assert.Single(model.Resources.OfType<AzureRoleAssignmentResource>());
Assert.Same(apim.Resource.Identity, roles.IdentityResource);
Assert.Contains(roles, apim.Resource.References);
var (_, rolesBicep) = await GetManifestWithBicep(roles, skipPreparer: true);
await Verify(new { ApiManagement = apimBicep, Roles = rolesBicep });
}
[Fact]
public async Task CustomKeyVaultResourceKeepsInlineRoleAssignment()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var vault = builder.AddResource(new CustomKeyVaultResource("vault"));
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
apim.AddKeyVaultNamedValue("upstream-secret", vault.Resource.GetSecret("upstream-secret"));
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var (_, bicep) = await GetManifestWithBicep(apim.Resource);
await Verify(bicep, "bicep");
}
[Fact]
public async Task MixedCrossScopeAndCustomKeyVaultResourcesPreserveAllRoleAssignments()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var crossScopeVault = builder.AddAzureKeyVault("shared-vault")
.PublishAsExisting("shared-vault", resourceGroup: "security-resources");
var customVault = builder.AddResource(new CustomKeyVaultResource("custom-vault"));
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
apim.AddKeyVaultNamedValue("shared-secret", crossScopeVault.GetSecret("shared-secret"));
apim.AddKeyVaultNamedValue("custom-secret", customVault.Resource.GetSecret("custom-secret"));
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var roleAssignments = Assert.Single(
model.Resources.OfType<AzureRoleAssignmentResource>(),
resource => resource.Name == "apim-identity-roles-shared-vault");
var (_, apimBicep) = await GetManifestWithBicep(apim.Resource);
var (_, rolesBicep) = await GetManifestWithBicep(roleAssignments, skipPreparer: true);
Assert.Equal(1, apimBicep.Split("Microsoft.Authorization/roleAssignments@", StringSplitOptions.None).Length - 1);
Assert.Equal(1, rolesBicep.Split("Microsoft.Authorization/roleAssignments@", StringSplitOptions.None).Length - 1);
}
[Fact]
public async Task GeneratedBicepIdentifiersCannotCollideWithUserResourceNames()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var environment = builder.AddAzureContainerAppEnvironment("env");
var backend = builder.AddDotnetProject("backend", "project.csproj", options => options.ExcludeLaunchProfile = true)
.WithHttpsEndpoint()
.WithComputeEnvironment(environment)
.WithExternalHttpEndpoints();
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
}).WithInboundPolicy("<set-header name=\"x-test\" exists-action=\"override\"><value>true</value></set-header>");
var api = apim.AddApi("apimPolicy", backend, "catalog")
.WithInboundPolicy("<set-header name=\"x-api\" exists-action=\"override\"><value>true</value></set-header>");
api.AddOperation("apimPolicyProxy", "GET", "/products")
.WithInboundPolicy("<set-header name=\"x-operation\" exists-action=\"override\"><value>true</value></set-header>");
apim.AddBackend(
"apimPolicyBackend",
ReferenceExpression.Create($"https://example.com"),
backendName: "physical-backend");
apim.AddNamedValue("apimPolicyPolicy", "api-policy-collision");
apim.AddNamedValue("apimPolicyProxyPolicy", "operation-policy-collision");
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var (_, bicep) = await GetManifestWithBicep(apim.Resource);
var declarations = bicep.Split('\n')
.Where(line => line.StartsWith("resource ", StringComparison.Ordinal) ||
line.StartsWith("param ", StringComparison.Ordinal) ||
line.StartsWith("output ", StringComparison.Ordinal))
.Select(line => line.Split(' ', StringSplitOptions.RemoveEmptyEntries)[1])
.ToArray();
Assert.Equal(declarations.Length, declarations.Distinct(StringComparer.Ordinal).Count());
Assert.Contains("resource _apim_", bicep);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task ExistingApiManagementRequiresExplicitSkuRegardlessOfCallOrder(bool adoptFirst)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new() { PublisherEmail = "api-owners@example.com", PublisherName = "Contoso APIs" });
if (adoptFirst)
{
apim.PublishAsExisting("shared-apim", resourceGroup: null);
}
apim.AddBackend("backend", ReferenceExpression.Create($"https://example.com"), new()
{
CircuitBreaker = new(),
});
if (!adoptFirst)
{
apim.PublishAsExisting("shared-apim", resourceGroup: null);
}
using var app = builder.Build();
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => ExecuteBeforeStartHooksAsync(app, default));
Assert.Contains("requires an explicit Sku matching the adopted service", exception.Message);
}
[Fact]
public void ExistingConsumptionRejectsCircuitBreakersAndOpenAIBackends()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.Consumption,
}).PublishAsExisting("shared-apim", resourceGroup: null);
var deployment = builder.AddAzureOpenAI("openai").AddDeployment("chat", "gpt-5-mini", "2025-08-07");
Assert.Throws<InvalidOperationException>(() =>
apim.AddBackend("backend", ReferenceExpression.Create($"https://example.com"), new() { CircuitBreaker = new() }));
Assert.Throws<InvalidOperationException>(() => apim.AddAzureOpenAIBackend("openai-backend", deployment));
Assert.Empty(apim.Resource.Backends);
}
[Fact]
public async Task ProvisioningCanBeCustomizedWithSdkResources()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
}).ConfigureInfrastructure(infrastructure =>
{
var service = infrastructure.GetProvisionableResources().OfType<ApiManagementService>().Single();
service.NotificationSenderEmail = "notifications@example.com";
});
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var (_, bicep) = await GetManifestWithBicep(apim.Resource);
await Verify(bicep, "bicep");
}
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public async Task AddAsExistingResourceUsesApiManagementAnnotationInConsumerModule(
bool existingConsumer,
bool overrideScope)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var existingName = builder.AddParameter("apim-name");
var existingResourceGroup = builder.AddParameter("apim-resource-group");
var existingSubscription = builder.AddParameter("apim-subscription");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.Developer,
}).AsExistingInResourceGroup(existingName, existingResourceGroup, existingSubscription);
if (overrideScope)
{
apim.Resource.Scope = new AzureBicepResourceScope("override-gateway-rg", "override-gateway-subscription");
}
var consumer = builder.AddAzureInfrastructure("consumer", infrastructure =>
{
var service = Assert.IsType<ApiManagementService>(apim.Resource.AddAsExistingResource(infrastructure));
Assert.Same(service, apim.Resource.AddAsExistingResource(infrastructure));
infrastructure.Add(new ProvisioningOutput("gatewayUrl", typeof(string))
{
Value = service.GatewayUri,
});
});
if (existingConsumer)
{
consumer.AsExistingInResourceGroup("consumer-name", "consumer-rg", "consumer-subscription");
}
var (_, bicep) = await GetManifestWithBicep(consumer.Resource, skipPreparer: true);
await Verify(bicep, "bicep").UseParameters(existingConsumer, overrideScope);
}
[Fact]
public async Task AddAsExistingResourceUsesNameOutputForNewService()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var consumer = builder.AddAzureInfrastructure("consumer", infrastructure =>
{
var service = Assert.IsType<ApiManagementService>(apim.Resource.AddAsExistingResource(infrastructure));
Assert.Same(service, apim.Resource.AddAsExistingResource(infrastructure));
infrastructure.Add(new ProvisioningOutput("gatewayUrl", typeof(string))
{
Value = service.GatewayUri,
});
});
var (_, bicep) = await GetManifestWithBicep(consumer.Resource, skipPreparer: true);
await Verify(bicep, "bicep");
}
[Fact]
public async Task ExistingApiManagementResourceCanProvisionManagedChildren()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.Developer,
}).PublishAsExisting("existing-apim", resourceGroup: "shared-infrastructure");
var backend = apim.AddBackend("catalog-backend", ReferenceExpression.Create($"https://example.com"));
apim.AddApi("catalog-api", "catalog").WithBackend(backend);
apim.AddNamedValue("gateway-name", "Contoso");
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var (manifest, bicep) = await GetManifestWithBicep(apim.Resource);
Assert.Contains("resource apim 'Microsoft.ApiManagement/service@2025-03-01-preview' existing", bicep);
Assert.Contains("name: 'existing-apim'", bicep);
Assert.Contains("resource catalog_api 'Microsoft.ApiManagement/service/apis@2024-05-01'", bicep);
Assert.DoesNotContain("publisherEmail:", bicep);
Assert.Contains("shared-infrastructure", manifest.ToJsonString());
Assert.DoesNotContain("output principalId", bicep);
}
[Theory]
[InlineData("backend", false)]
[InlineData("backend", true)]
[InlineData("pool", false)]
[InlineData("pool", true)]
[InlineData("openai", false)]
[InlineData("openai", true)]
[InlineData("key-vault", false)]
[InlineData("key-vault", true)]
public async Task ExistingApiManagementRejectsUnconfirmedIdentityRegardlessOfCallOrder(string feature, bool adoptFirst)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.Developer,
});
if (adoptFirst)
{
apim.PublishAsExisting("existing-apim", resourceGroup: null);
}
if (feature == "key-vault")
{
var vault = builder.AddAzureKeyVault("vault");
apim.AddKeyVaultNamedValue("upstream-secret", vault.GetSecret("upstream-secret"));
}
else if (feature == "openai")
{
var deployment = builder.AddAzureOpenAI("openai").AddDeployment("chat", "gpt-5-mini", "2025-08-07");
var backend = apim.AddAzureOpenAIBackend("openai-backend", deployment);
apim.AddOpenAIApi("chat-api", "chat").WithBackend(backend);
}
else
{
var backend = apim.AddBackend("catalog-backend", ReferenceExpression.Create($"https://example.com"), new()
{
ManagedIdentityResource = "api://catalog",
});
var api = apim.AddApi("catalog-api", "catalog");
if (feature == "pool")
{
api.WithBackend(apim.AddBackendPool("catalog-pool").WithBackend(backend));
}
else
{
api.WithBackend(backend);
}
}
if (!adoptFirst)
{
apim.PublishAsExisting("existing-apim", resourceGroup: null);
}
using var app = builder.Build();
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => ExecuteBeforeStartHooksAsync(app, default));
Assert.Equal(
"Existing API Management resource 'apim' uses managed-identity backends or Key Vault-backed named values, " +
"but its user-assigned identity is not configured as an existing resource. " +
"Supply an already-attached identity using WithAzureUserAssignedIdentity(...) and configure it with PublishAsExisting(...). " +
"Aspire does not attach identities to existing services.",
exception.Message);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task ExistingApiManagementAllowsKeyVaultWithSuppliedIdentityRegardlessOfCallOrder(bool configureIdentityFirst)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.Developer,
});
var defaultIdentity = apim.Resource.Identity;
var identity = builder.AddAzureUserAssignedIdentity("shared-identity")
.PublishAsExisting("shared-identity", resourceGroup: null);
if (configureIdentityFirst)
{
ConfigureIdentity();
}
var vault = builder.AddAzureKeyVault("vault");
apim.AddKeyVaultNamedValue("upstream-secret", vault.GetSecret("upstream-secret"));
apim.PublishAsExisting("existing-apim", resourceGroup: null);
if (!configureIdentityFirst)
{
ConfigureIdentity();
}
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
Assert.True(apim.Resource.IsExisting());
Assert.True(apim.Resource.Identity.IsExisting());
Assert.Same(identity.Resource, apim.Resource.Identity);
Assert.DoesNotContain(defaultIdentity, builder.Resources);
Assert.Empty(defaultIdentity.Annotations.OfType<RoleAssignmentAnnotation>());
var roles = Assert.Single(builder.Resources.OfType<AzureRoleAssignmentResource>());
Assert.Same(identity.Resource, roles.IdentityResource);
Assert.Contains(roles, apim.Resource.References);
void ConfigureIdentity()
{
Assert.Same(apim, apim.WithAzureUserAssignedIdentity(identity));
}
}
[Fact]
public void SuppliedIdentityRejectsNullArguments()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var identity = builder.AddAzureUserAssignedIdentity("identity");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
Assert.Throws<ArgumentNullException>(() =>
AzureApiManagementExtensions.WithAzureUserAssignedIdentity(null!, identity));
Assert.Throws<ArgumentNullException>(() => apim.WithAzureUserAssignedIdentity(null!));
}
[Fact]
public async Task ExistingApiManagementRejectsSuppliedNewIdentity()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var identity = builder.AddAzureUserAssignedIdentity("new-identity");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.Developer,
}).WithAzureUserAssignedIdentity(identity)
.PublishAsExisting("existing-apim", resourceGroup: null);
var backend = apim.AddBackend("catalog-backend", ReferenceExpression.Create($"https://example.com"), new()
{
ManagedIdentityResource = "api://catalog",
});
apim.AddApi("catalog-api", "catalog").WithBackend(backend);
using var app = builder.Build();
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => ExecuteBeforeStartHooksAsync(app, default));
Assert.Contains("user-assigned identity is not configured as an existing resource", exception.Message);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task SuppliedIdentityKeepsInferredBackendAndKeyVaultRolesRegardlessOfCallOrder(bool identityFirst)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var identity = builder.AddAzureUserAssignedIdentity("shared-identity");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var defaultIdentity = apim.Resource.Identity;
if (identityFirst)
{
apim.WithAzureUserAssignedIdentity(identity);
}
var storage = builder.AddAzureStorage("storage");
var backend = apim.AddBlobStorageBackend("blob-backend", storage.AddBlobs("blobs"));
apim.AddApi("blob-api", "blob").WithBackend(backend);
var vault = builder.AddAzureKeyVault("vault");
apim.AddKeyVaultNamedValue("upstream-secret", vault.GetSecret("upstream-secret"));
if (!identityFirst)
{
apim.WithAzureUserAssignedIdentity(identity);
}
apim.WithAzureUserAssignedIdentity(identity);
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
Assert.Same(identity.Resource, apim.Resource.Identity);
Assert.Same(identity.Resource, apim.Resource.PrincipalId.Resource);
Assert.Same(identity.Resource, apim.Resource.Annotations.OfType<AppIdentityAnnotation>().Single().IdentityResource);
Assert.DoesNotContain(defaultIdentity, builder.Resources);
Assert.Empty(defaultIdentity.Annotations.OfType<RoleAssignmentAnnotation>());
var roles = builder.Resources.OfType<AzureRoleAssignmentResource>().OrderBy(role => role.Name).ToArray();
Assert.Collection(roles,
role =>
{
Assert.Same(storage.Resource, role.TargetAzureResource);
Assert.Same(identity.Resource, role.IdentityResource);
},
role =>
{
Assert.Same(vault.Resource, role.TargetAzureResource);
Assert.Same(identity.Resource, role.IdentityResource);
});
var (_, bicep) = await GetManifestWithBicep(apim.Resource, skipPreparer: true);
await Verify(bicep, "bicep");
}
[Fact]
public async Task ReplacingSharedIdentityMovesOnlyThisServicesInferredRoles()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var sharedIdentity = builder.AddAzureUserAssignedIdentity("shared-identity");
var replacementIdentity = builder.AddAzureUserAssignedIdentity("replacement-identity");
var first = builder.AddAzureApiManagement("first", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
}).WithAzureUserAssignedIdentity(sharedIdentity);
var second = builder.AddAzureApiManagement("second", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
}).WithAzureUserAssignedIdentity(sharedIdentity);
var firstVault = builder.AddAzureKeyVault("first-vault");
var secondVault = builder.AddAzureKeyVault("second-vault");
first.AddKeyVaultNamedValue("first-secret", firstVault.GetSecret("secret"));
second.AddKeyVaultNamedValue("second-secret", secondVault.GetSecret("secret"));
first.WithAzureUserAssignedIdentity(replacementIdentity);
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
Assert.Contains(sharedIdentity.Resource, builder.Resources);
Assert.Same(secondVault.Resource, Assert.Single(sharedIdentity.Resource.Annotations.OfType<RoleAssignmentAnnotation>()).Target);
Assert.Same(firstVault.Resource, Assert.Single(replacementIdentity.Resource.Annotations.OfType<RoleAssignmentAnnotation>()).Target);
Assert.Contains(replacementIdentity.Resource, first.Resource.References);
Assert.DoesNotContain(sharedIdentity.Resource, first.Resource.References);
Assert.Contains(sharedIdentity.Resource, second.Resource.References);
}
[Fact]
public async Task ExistingApiManagementDoesNotRequireIdentityConfirmationInRunMode()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.Developer,
}).PublishAsExisting("existing-apim", resourceGroup: null);
var backend = apim.AddBackend("catalog-backend", ReferenceExpression.Create($"https://example.com"), new()
{
ManagedIdentityResource = "api://catalog",
});
apim.AddApi("catalog-api", "catalog").WithBackend(backend);
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
Assert.Empty(builder.Resources);
Assert.True(apim.Resource.HasDefaultIdentity);
}
[Fact]
public async Task ExistingApiManagementUsesUserAssignedIdentityWithoutMutatingService()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.Developer,
}).PublishAsExisting("existing-apim", resourceGroup: null);
var identity = builder.AddAzureUserAssignedIdentity("apim-existing-identity")
.PublishAsExisting("shared-identity", resourceGroup: null);
apim.WithAzureUserAssignedIdentity(identity);
var backend = apim.AddBackend(
"catalog-backend",
ReferenceExpression.Create($"https://example.com"),
new()
{
ManagedIdentityResource = "api://catalog",
});
apim.AddApi("catalog-api", "catalog").WithBackend(backend);
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var (_, bicep) = await GetManifestWithBicep(apim.Resource, skipPreparer: true);
await Verify(bicep, "bicep");
}
[Fact]
public async Task ExistingApiManagementCanUseExistingUserAssignedIdentityAndKeyVaultNamedValues()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.Developer,
}).PublishAsExisting("existing-apim", resourceGroup: null);
var identity = builder.AddAzureUserAssignedIdentity("apim-existing-identity")
.PublishAsExisting("shared-identity", resourceGroup: "identity-resources");
apim.WithAzureUserAssignedIdentity(identity);
var vault = builder.AddAzureKeyVault("vault")
.PublishAsExisting("shared-vault", resourceGroup: "security-resources");
apim.AddKeyVaultNamedValue("upstream-secret", vault.GetSecret("upstream-secret"));
var backend = apim.AddBackend(
"catalog-backend",
ReferenceExpression.Create($"https://example.com"),
new()
{
ManagedIdentityResource = "api://catalog",
});
apim.AddApi("catalog-api", "catalog").WithBackend(backend);
await VerifyAllAzureBicep(builder);
}
[Fact]
public async Task ExistingApiManagementRejectsCrossResourceGroupDiagnostics()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var insights = builder.AddAzureApplicationInsights("insights");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.Developer,
}).PublishAsExisting("existing-apim", resourceGroup: "shared-infrastructure")
.WithApplicationInsights(insights);
using var app = builder.Build();
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => ExecuteBeforeStartHooksAsync(app, default));
Assert.Contains("cannot configure diagnostics", exception.Message);
}
[Fact]
public async Task InternalContainerAppBackendRequiresSameVirtualNetworkEvenWhenIngressIsExternal()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var backendVnet = builder.AddAzureVirtualNetwork("backend-vnet");
var backendSubnet = backendVnet.AddSubnet("backend-subnet", "10.0.0.0/23");
var apimVnet = builder.AddAzureVirtualNetwork("apim-vnet");
var apimSubnet = apimVnet.AddSubnet("apim-subnet", "10.1.0.0/24");
var environment = builder.AddAzureContainerAppEnvironment("env")
.WithDelegatedSubnet(backendSubnet)
.WithInternalLoadBalancer(backendVnet);
var backend = builder.AddDotnetProject("backend", "project.csproj", options => options.ExcludeLaunchProfile = true)
.WithHttpsEndpoint()
.WithExternalHttpEndpoints()
.WithComputeEnvironment(environment);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
Sku = AzureApiManagementSku.Premium,
}).WithClassicVirtualNetwork(apimSubnet, AzureApiManagementVirtualNetworkMode.External);
apim.AddApi("catalog-api", backend, "catalog");
using var app = builder.Build();
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => ExecuteBeforeStartHooksAsync(app, default));
Assert.Contains("must be injected into the same virtual network", exception.Message);
}
[Fact]
public void GeneratedIdentifiersAreBoundedAndStable()
{
var value = new string('a', 100);
var identifier = AzureApiManagementExtensions.CreateBoundedIdentifier(value, 80);
Assert.Equal(80, identifier.Length);
Assert.Equal(identifier, AzureApiManagementExtensions.CreateBoundedIdentifier(value, 80));
Assert.StartsWith(new string('a', 71), identifier, StringComparison.Ordinal);
}
[Fact]
public void FoundryBackendMustUseOpenAIFormat()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var foundry = builder.AddFoundry("foundry");
var model = foundry.AddDeployment("embedding", "embedding-model", "1", "Microsoft");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var exception = Assert.Throws<InvalidOperationException>(() => apim.AddFoundryBackend("embedding-backend", model));
Assert.Contains("Only OpenAI-format deployments", exception.Message);
}
[Fact]
public async Task BlobStorageBackendPoolUsesManagedIdentityAndRoleAssignments()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var primary = builder.AddAzureStorage("storage-primary").AddBlobs("blobs-primary");
var secondary = builder.AddAzureStorage("storage-secondary").AddBlobs("blobs-secondary");
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var primaryBackend = apim.AddBlobStorageBackend("blob-primary-backend", primary);
var secondaryBackend = apim.AddBlobStorageBackend("blob-secondary-backend", secondary);
var pool = apim.AddBackendPool("blob-pool")
.WithBackend(primaryBackend, weight: 3)
.WithBackend(secondaryBackend);
apim.AddApi("blob-api", "blobs", subscriptionRequired: false)
.WithBackend(pool)
.WithInboundPolicy("<set-header name=\"x-ms-version\" exists-action=\"override\"><value>2023-11-03</value></set-header>");
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var (manifest, bicep) = await GetManifestWithBicep(apim.Resource);
var manifestJson = manifest.ToJsonString();
Assert.Contains("storage-primary.outputs.blobEndpoint", manifestJson);
Assert.Contains("storage-secondary.outputs.blobEndpoint", manifestJson);
Assert.Contains("authentication-managed-identity resource=\"https://storage.azure.com/\" client-id=", bicep);
var roles = builder.Resources.OfType<AzureRoleAssignmentResource>().ToArray();
Assert.Equal(2, roles.Length);
foreach (var role in roles)
{
Assert.Same(apim.Resource.Identity, role.IdentityResource);
var (_, roleBicep) = await GetManifestWithBicep(role, skipPreparer: true);
Assert.Contains("2a2b9908-6ea1-4ae2-8e65-a410df84e7d1", roleBicep);
}
}
[Fact]
public async Task GenericBackendOptionsGenerateBackendConfiguration()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var backend = apim.AddBackend(
"custom-backend",
ReferenceExpression.Create($"https://example.com/base"),
new AzureApiManagementBackendOptions
{
Title = "Custom SOAP backend",
Protocol = AzureApiManagementBackendProtocol.Soap,
ValidateCertificateName = false,
CircuitBreaker = new AzureApiManagementCircuitBreakerOptions
{
Name = "serverErrors",
FailureCount = 3,
FailureIntervalSeconds = 30,
TripDurationSeconds = 60,
StatusCodeRanges = [new(500, 599)],
},
},
backendName: "custom\"backend");
apim.AddApi("custom-api", "custom")
.WithBackend(backend);
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var (_, bicep) = await GetManifestWithBicep(apim.Resource);
Assert.Contains("protocol: 'soap'", bicep);
Assert.Contains("validateCertificateName: false", bicep);
Assert.Contains("name: 'serverErrors'", bicep);
Assert.Contains("interval: 'PT30S'", bicep);
Assert.Contains("tripDuration: 'PT1M'", bicep);
Assert.Contains("backend-id=\"custom"backend\"", bicep);
}
[Fact]
public void BackendPoolRejectsDifferentManagedIdentityResources()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var storageBackend = apim.AddBackend(
"storage",
ReferenceExpression.Create($"https://storage.example.com"),
new AzureApiManagementBackendOptions
{
ManagedIdentityResource = "https://storage.azure.com/",
});
var cognitiveBackend = apim.AddBackend(
"cognitive",
ReferenceExpression.Create($"https://cognitive.example.com"),
new AzureApiManagementBackendOptions
{
ManagedIdentityResource = "https://cognitiveservices.azure.com",
});
var pool = apim.AddBackendPool("pool").WithBackend(storageBackend);
var exception = Assert.Throws<InvalidOperationException>(() => pool.WithBackend(cognitiveBackend));
Assert.Contains("must use the same managed-identity resource URI", exception.Message);
}
[Fact]
public void BackendConfigurationEnforcesApiManagementLimits()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var apim = builder.AddAzureApiManagement("apim", new()
{
PublisherEmail = "api-owners@example.com",
PublisherName = "Contoso APIs",
});
var invalidTitle = Assert.Throws<ArgumentException>(() => apim.AddBackend(
"invalid-title",
ReferenceExpression.Create($"https://example.com"),
new AzureApiManagementBackendOptions
{
Title = "",
}));
var invalidRange = Assert.Throws<ArgumentOutOfRangeException>(() => apim.AddBackend(
"invalid-range",
ReferenceExpression.Create($"https://example.com"),
new AzureApiManagementBackendOptions
{
CircuitBreaker = new AzureApiManagementCircuitBreakerOptions
{
StatusCodeRanges = [new(199, 200)],
},
}));
var tooManyRanges = Assert.Throws<ArgumentException>(() => apim.AddBackend(
"too-many-ranges",
ReferenceExpression.Create($"https://example.com"),
new AzureApiManagementBackendOptions
{
CircuitBreaker = new AzureApiManagementCircuitBreakerOptions
{
StatusCodeRanges = Enumerable.Repeat(new AzureApiManagementStatusCodeRange(500, 500), 11).ToArray(),
},
}));
Assert.Contains("backend title", invalidTitle.Message);
Assert.Contains("between 200 and 599", invalidRange.Message);
Assert.Contains("more than 10", tooManyRanges.Message);
}
[Fact]
public async Task InternalContainerAppEnvironmentGeneratesPrivateDns()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var vnet = builder.AddAzureVirtualNetwork("vnet");
var subnet = vnet.AddSubnet("container-apps-subnet", "10.0.0.0/23");
var environment = builder.AddAzureContainerAppEnvironment("env")
.WithDelegatedSubnet(subnet)
.WithInternalLoadBalancer(vnet);
using var app = builder.Build();
await ExecuteBeforeStartHooksAsync(app, default);
var privateDns = Assert.Single(builder.Resources.OfType<AzurePrivateDnsZoneResource>());
Assert.Equal("env-private-dns", privateDns.Name);
Assert.Null(privateDns.ZoneName);
var ingress = Assert.IsAssignableFrom<IAzureInternalIngressResource>(environment.Resource);
var aRecord = Assert.Single(privateDns.ARecords);
Assert.Equal("*", aRecord.Name);
Assert.Equal(ingress.StaticIp.ValueExpression, aRecord.Address.ValueExpression);
var link = Assert.Single(builder.Resources.OfType<AzurePrivateDnsZoneVNetLinkResource>());
Assert.Same(privateDns, link.Parent);
Assert.Same(vnet.Resource, link.VNet);
Assert.Same(link, Assert.Single(privateDns.VNetLinks).Value);
var (_, environmentBicep) = await GetManifestWithBicep(environment.Resource);
var (_, privateDnsBicep) = await GetManifestWithBicep(privateDns);
Assert.Same(vnet.Resource, environment.Resource.Annotations.OfType<InternalLoadBalancerAnnotation>().Single().VirtualNetwork);
Assert.Contains("internal: true", environmentBicep);
Assert.Contains("output AZURE_CONTAINER_APPS_ENVIRONMENT_STATIC_IP string", environmentBicep);
Assert.Contains("Microsoft.Network/privateDnsZones@2024-06-01", privateDnsBicep);
Assert.Contains("name: '*'", privateDnsBicep);
Assert.Contains("ipv4Address:", privateDnsBicep);
Assert.Contains("Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01", privateDnsBicep);
await Verify(privateDnsBicep, "bicep");
}
[Fact]
public async Task InternalContainerAppEnvironmentRevalidatesSubnetDelegationDuringGeneration()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var vnet = builder.AddAzureVirtualNetwork("vnet");
var subnet = vnet.AddSubnet("container-apps-subnet", "10.0.0.0/23");
builder.AddAzureContainerAppEnvironment("env")
.WithDelegatedSubnet(subnet)
.WithInternalLoadBalancer(vnet);
subnet.WithServiceDelegation("Microsoft.Web/serverFarms");
var privateDns = Assert.Single(builder.Resources.OfType<AzurePrivateDnsZoneResource>());
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => GetManifestWithBicep(privateDns));
Assert.Equal(
"The delegated subnet for Azure resource 'env' must remain delegated to " +
"'Microsoft.App/environments' for an internal load balancer, but it is " +
"delegated to 'Microsoft.Web/serverFarms'.",
exception.Message);
}
[Theory]
[InlineData(4)]
[InlineData(64)]
public void InternalContainerAppEnvironmentBoundsPrivateDnsResourceName(int virtualNetworkNameLength)
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var vnet = builder.AddAzureVirtualNetwork(new string('v', virtualNetworkNameLength));
var subnet = vnet.AddSubnet("container-apps-subnet", "10.0.0.0/23");
var environmentName = new string('e', 53);
builder.AddAzureContainerAppEnvironment(environmentName)
.WithDelegatedSubnet(subnet)
.WithInternalLoadBalancer(vnet);
var privateDns = Assert.Single(builder.Resources.OfType<AzurePrivateDnsZoneResource>());
Assert.Equal(64, privateDns.Name.Length);
Assert.StartsWith($"{environmentName}-p", privateDns.Name, StringComparison.Ordinal);
Assert.Matches("-[0-9a-f]{8}$", privateDns.Name);
var link = Assert.Single(builder.Resources.OfType<AzurePrivateDnsZoneVNetLinkResource>());
Assert.Equal(64, link.Name.Length);
Assert.StartsWith($"{environmentName}-p", link.Name, StringComparison.Ordinal);
Assert.Matches("-[0-9a-f]{8}$", link.Name);
}
[Fact]
public void InternalContainerAppEnvironmentRejectsDifferentVirtualNetwork()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var vnet = builder.AddAzureVirtualNetwork("vnet");
var otherVnet = builder.AddAzureVirtualNetwork("other-vnet");
var subnet = vnet.AddSubnet("container-apps-subnet", "10.0.0.0/23");
var environment = builder.AddAzureContainerAppEnvironment("env")
.WithDelegatedSubnet(subnet);
var exception = Assert.Throws<InvalidOperationException>(
() => environment.WithInternalLoadBalancer(otherVnet));
Assert.Contains("must belong to virtual network 'other-vnet'", exception.Message);
}
private sealed class CustomKeyVaultResource(string name)
: AzureBicepResource(
name,
templateString:
"""
output name string = 'vault'
output vaultUri string = 'https://vault.vault.azure.net/'
"""),
IAzureKeyVaultResource
{
public BicepOutputReference VaultUriOutputReference => new("vaultUri", this);
public BicepOutputReference NameOutputReference => new("name", this);
public Func<IAzureKeyVaultSecretReference, CancellationToken, Task<string?>>? SecretResolver { get; set; }
public IAzureKeyVaultSecretReference GetSecret(string secretName) => new CustomKeyVaultSecretReference(secretName, this);
}
private sealed class CustomKeyVaultSecretReference(
string secretName,
CustomKeyVaultResource resource) : IAzureKeyVaultSecretReference
{
public string SecretName { get; } = secretName;
public IAzureKeyVaultResource Resource { get; } = resource;
public IResource? SecretOwner { get; set; }
string IManifestExpressionProvider.ValueExpression => $"{{{resource.Name}.secrets.{SecretName}}}";
ValueTask<string?> IValueProvider.GetValueAsync(CancellationToken cancellationToken) =>
throw new InvalidOperationException($"Secret '{SecretName}' was not resolved.");
}
}