File: Publishing\BackingResourceProjectionTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Radius.Tests\Aspire.Hosting.Radius.Tests.csproj (Aspire.Hosting.Radius.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIRERADIUS004
 
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Radius.Publishing;
using Aspire.Hosting.Radius.Publishing.Constructs;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
 
namespace Aspire.Hosting.Radius.Tests.Publishing;
 
/// <summary>
/// Tests for https://github.com/microsoft/aspire/issues/18935.
///
/// Backing resources (caches, databases, queues) are provisioned by a Radius <em>recipe</em>, not as
/// a <c>Radius.Compute/containers</c> workload. The recipe owns the Kubernetes Service name and the
/// credentials, so the container recipe's <c>{name}-{name}</c> ClusterIP rule - and Aspire's own
/// generated password parameter - are both wrong for them.
///
/// Every consumer-visible value is therefore projected from the backing resource's own Radius
/// construct: <c>properties.host</c>/<c>properties.port</c> for the address, and either
/// <c>listSecrets()</c> (legacy <c>Applications.*</c> types) or the schema properties Aspire itself
/// supplies (<c>Radius.*</c> UDTs) for credentials. Because the substitution happens at the value
/// level, connection strings, URIs and splatted connection properties all compose correctly without
/// this package duplicating any connection-string format.
/// </summary>
public class BackingResourceProjectionTests
{
    private static string GenerateBicep(Action<IDistributedApplicationBuilder> configure, string environmentName = "myenv")
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        builder.AddRadiusEnvironment(environmentName);
        configure(builder);
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var radiusEnv = model.Resources.OfType<RadiusEnvironmentResource>().First();
        RadiusTestHelper.AttachDeploymentTargets(radiusEnv, model);
        var context = new RadiusBicepPublishingContext(radiusEnv);
        return context.GenerateBicep(model);
    }
 
    /// <summary>
    /// The full blast radius of the bug in one snapshot: a container referencing Redis, a Postgres
    /// database, MongoDB, RabbitMQ and SQL Server. A single <c>WithReference</c> emits both
    /// <c>ConnectionStrings__x</c> and the splatted <c>X_HOST</c>/<c>X_PORT</c>/<c>X_PASSWORD</c>/
    /// <c>X_URI</c> properties, so all of them must be projected, not just the connection string.
    /// Also pins the required schema properties the UDT types take.
    /// </summary>
    [Fact]
    public Task AllBackingResourceTypes_ProjectRecipeOutputs()
    {
        var bicep = GenerateBicep(b =>
        {
            var cache = b.AddRedis("cache");
            var pgdb = b.AddPostgres("pg").AddDatabase("pgdb");
            var mongo = b.AddMongoDB("mongo");
            var rabbit = b.AddRabbitMQ("rabbit", userName: b.AddParameter("rabbituser"));
            var sql = b.AddSqlServer("sqlserver");
 
            b.AddContainer("api", "myapp/api", "latest")
                .WithHttpEndpoint(targetPort: 8080)
                .WithReference(cache)
                .WithReference(pgdb)
                .WithReference(mongo)
                .WithReference(rabbit)
                .WithReference(sql);
        });
 
        // The snapshot is the assertion: it pins the whole emitted document, so an endpoint-derived
        // `{name}-{name}` Kubernetes FQDN reappearing for any backing resource shows up as a diff.
        return Verify(bicep, extension: "bicep");
    }
 
    /// <summary>
    /// A directly-referenced endpoint property (rather than a whole connection string) goes through
    /// the same projection. <c>Port</c> is included because it never evaluates the lazy host
    /// expression, so guarding only the host would let a wrong value through.
    /// </summary>
    [Fact]
    public Task DirectEndpointPropertyReference_ProjectsRecipeOutputs()
    {
        var bicep = GenerateBicep(b =>
        {
            var cache = b.AddRedis("cache");
            b.AddContainer("api", "myapp/api", "latest")
                .WithHttpEndpoint(targetPort: 8080)
                .WithEnvironment("CUSTOM_HOST", cache.GetEndpoint("tcp").Property(EndpointProperty.Host))
                .WithEnvironment("CUSTOM_PORT", cache.GetEndpoint("tcp").Property(EndpointProperty.Port))
                .WithEnvironment("CUSTOM_URL", cache.GetEndpoint("tcp").Property(EndpointProperty.Url));
        });
 
        return Verify(bicep, extension: "bicep");
    }
 
    /// <summary>
    /// Container-to-container service discovery still uses the <c>{name}-{name}</c> ClusterIP rule
    /// the container recipe creates. Only backing resources are re-routed.
    /// </summary>
    [Fact]
    public void ContainerToContainerServiceDiscovery_IsUnchanged()
    {
        var bicep = GenerateBicep(b =>
        {
            var backend = b.AddContainer("backend", "myapp/backend", "latest")
                .WithHttpEndpoint(targetPort: 8080);
 
            b.AddContainer("api", "myapp/api", "latest")
                .WithHttpEndpoint(targetPort: 8080)
                .WithEnvironment("BACKEND_HOST", backend.GetEndpoint("http").Property(EndpointProperty.Host));
        });
 
        Assert.Contains("backend-backend.default.svc.cluster.local", bicep, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// Aspire's generated password is meaningless for a legacy backing resource - the recipe
    /// generates its own, or (Redis) provisions none at all - so the parameter must not be emitted
    /// as the value a consumer reads.
    /// </summary>
    /// <remarks>
    /// Redis is deliberately the subject because it is the strongest form of the guarantee: its
    /// recipe deploys an unauthenticated server, so the correct emitted credential is empty and any
    /// `cache_password` in the document would be a leak of a run-mode-only value. Read the snapshot
    /// with that in mind - the empty `password=` in `ConnectionStrings__cache` is the *projected*
    /// value, not a redaction, and this file therefore says nothing about the `listSecrets()` path.
    /// That path is pinned for the types that do have a recipe-generated credential by
    /// <see cref="AllBackingResourceTypes_ProjectRecipeOutputs"/> (Mongo, SQL Server) and
    /// asserted directly - so an upstream change to a connection-string format cannot silently
    /// erase it - by <c>BackingResourceValueResolutionTests.UriFormattedValues_AreEscapedInTheEmittedBicep</c>
    /// and <c>BackingResourceValueResolutionTests.RedisPasswordIsNotProjected_BecauseTheRecipeDeploysAnUnauthenticatedServer</c>.
    /// </remarks>
    [Fact]
    public Task UnauthenticatedBackingResource_DoesNotLeakAspireGeneratedPasswordParameter()
    {
        var bicep = GenerateBicep(b =>
        {
            var cache = b.AddRedis("cache");
            b.AddContainer("api", "myapp/api", "latest").WithReference(cache);
        });
 
        // Snapshotted rather than asserted with DoesNotContain: the guarantee is about the whole
        // document (no `param cache_password` anywhere, and every consumer value composed from the
        // recipe's own outputs), which a substring absence check cannot express. Redis's recipe
        // publishes no credential output at all, so the correct projected credential here is
        // *empty* — there is no `listSecrets()` in this snapshot to look for, and adding one would
        // be the regression.
        return Verify(bicep, extension: "bicep");
    }
 
    /// <summary>
    /// A backing resource deployed by a different Radius environment has no construct in this
    /// document to project from, and another deployment's recipe outputs are not reachable from
    /// this Bicep. That must fail loudly rather than emit an unresolvable address - and in
    /// particular must not be swallowed by the publish-time "skip unresolvable endpoint" path.
    /// </summary>
    [Fact]
    public void BackingResourceInDifferentEnvironment_FailsWithActionableMessage()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var envA = builder.AddRadiusEnvironment("enva");
        var envB = builder.AddRadiusEnvironment("envb");
 
        var cache = builder.AddRedis("cache").WithComputeEnvironment(envB);
        builder.AddContainer("api", "myapp/api", "latest")
            .WithComputeEnvironment(envA)
            .WithReference(cache);
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var radiusEnvA = model.Resources.OfType<RadiusEnvironmentResource>().First(e => e.Name == "enva");
        RadiusTestHelper.AttachDeploymentTargets(radiusEnvA, model);
        var context = new RadiusBicepPublishingContext(radiusEnvA);
 
        // The concrete type matters: the publisher's env-var loop skips values that raise
        // RadiusUnresolvableValueException, so this must not be one of those.
        var ex = Assert.Throws<RadiusBackingResourceProjectionException>(() => context.GenerateBicep(model));
        Assert.Equal("cache", ex.Resource.Name);
        Assert.Contains("same Radius environment", ex.Message, StringComparison.Ordinal);
        Assert.Contains("ASPIRERADIUS069", ex.Message, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// A Radius recipe provisions a single database, so a server resource with two databases cannot
    /// be satisfied. Emitting one would leave consumers of the other pointed at a database that
    /// does not exist.
    /// </summary>
    [Fact]
    public void MultipleDatabasesOnOneBackingResource_FailsWithActionableMessage()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(b =>
        {
            var pg = b.AddPostgres("pg");
            var first = pg.AddDatabase("first");
            var second = pg.AddDatabase("second");
            b.AddContainer("api", "myapp/api", "latest")
                .WithReference(first)
                .WithReference(second);
        }));
 
        Assert.Contains("single database", ex.Message, StringComparison.Ordinal);
        Assert.Contains("ASPIRERADIUS072", ex.Message, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// Each Radius recipe manages its own credential, so a password parameter shared by two backing
    /// resources has no single correct substitution. Silently picking one would hand a consumer the
    /// other resource's password.
    /// </summary>
    [Fact]
    public void SharedPasswordParameterAcrossBackingResources_FailsWithActionableMessage()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(b =>
        {
            var shared = b.AddParameter("shared", secret: true);
            var sqlA = b.AddSqlServer("sqla", password: shared);
            var sqlB = b.AddSqlServer("sqlb", password: shared);
            b.AddContainer("api", "myapp/api", "latest")
                .WithReference(sqlA)
                .WithReference(sqlB);
        }));
 
        Assert.Contains("its own parameter", ex.Message, StringComparison.Ordinal);
        Assert.Contains("ASPIRERADIUS070", ex.Message, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// Projected values name a backing resource by Bicep identifier, so a
    /// <c>ConfigureRadiusInfrastructure</c> callback that renames the resource must not leave the
    /// environment variables pointing at a symbol that no longer exists.
    /// </summary>
    [Fact]
    public Task CallbackRenamingBackingResource_RewiresProjectedEnvironmentValues()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        builder.AddRadiusEnvironment("myenv")
            .ConfigureRadiusInfrastructure(opts =>
            {
                opts.ResourceTypeInstances.Single().BicepIdentifier = "renamed_cache";
            });
 
        var cache = builder.AddRedis("cache");
        builder.AddContainer("api", "myapp/api", "latest").WithReference(cache);
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var radiusEnv = model.Resources.OfType<RadiusEnvironmentResource>().First();
        RadiusTestHelper.AttachDeploymentTargets(radiusEnv, model);
        var bicep = new RadiusBicepPublishingContext(radiusEnv).GenerateBicep(model);
 
        // Snapshotted so the assertion covers every rewritten value, not just the two that were
        // spot-checked; a value left pointing at the old `cache` symbol would show as a diff.
        return Verify(bicep, extension: "bicep");
    }
 
    /// <summary>
    /// Removing a backing resource that a container's environment reads from leaves those values
    /// pointing at a symbol that no longer exists, which must fail rather than emit invalid Bicep.
    /// </summary>
    [Fact]
    public void CallbackRemovingBackingResource_FailsWithActionableMessage()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        builder.AddRadiusEnvironment("myenv")
            .ConfigureRadiusInfrastructure(opts => opts.ResourceTypeInstances.Clear());
 
        var cache = builder.AddRedis("cache");
        builder.AddContainer("api", "myapp/api", "latest").WithReference(cache);
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var radiusEnv = model.Resources.OfType<RadiusEnvironmentResource>().First();
        RadiusTestHelper.AttachDeploymentTargets(radiusEnv, model);
        var context = new RadiusBicepPublishingContext(radiusEnv);
 
        var ex = Assert.Throws<InvalidOperationException>(() => context.GenerateBicep(model));
        Assert.Contains("removed or replaced that resource", ex.Message, StringComparison.Ordinal);
        Assert.Contains("ASPIRERADIUS074", ex.Message, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// A callback that both renames a backing resource and sets the environment value itself must
    /// keep the callback's value - <c>ConfigureRadiusInfrastructure</c> is last-write-wins.
    /// </summary>
    [Fact]
    public void CallbackOverridingProjectedValue_IsNotRebuilt()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        builder.AddRadiusEnvironment("myenv")
            .ConfigureRadiusInfrastructure(opts =>
            {
                opts.ResourceTypeInstances.Single().BicepIdentifier = "renamed_cache";
                opts.Containers.Single().Env["CACHE_HOST"] = new ContainerEnvVarConstruct { Value = "explicit-host" };
            });
 
        var cache = builder.AddRedis("cache");
        builder.AddContainer("api", "myapp/api", "latest").WithReference(cache);
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var radiusEnv = model.Resources.OfType<RadiusEnvironmentResource>().First();
        RadiusTestHelper.AttachDeploymentTargets(radiusEnv, model);
        var bicep = new RadiusBicepPublishingContext(radiusEnv).GenerateBicep(model);
 
        Assert.Contains("'explicit-host'", bicep, StringComparison.Ordinal);
        // Values the callback did not touch are still repaired.
        Assert.Contains("renamed_cache.properties.port", bicep, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// Two UDT resources may legitimately share a password parameter: the same parameter is passed
    /// into both recipes, so every consumer reads back the value Aspire supplied. Only sharing that
    /// involves a recipe-generated (<c>listSecrets()</c>) credential is ambiguous.
    /// </summary>
    [Fact]
    public void SharedPasswordParameterAcrossUdtResources_IsAllowed()
    {
        var bicep = GenerateBicep(b =>
        {
            var shared = b.AddParameter("shared", secret: true);
            var pgA = b.AddPostgres("pga", password: shared);
            var pgB = b.AddPostgres("pgb", password: shared);
            b.AddContainer("api", "myapp/api", "latest")
                .WithReference(pgA)
                .WithReference(pgB);
        });
 
        Assert.Contains("pga.properties.host", bicep, StringComparison.Ordinal);
        Assert.Contains("pgb.properties.host", bicep, StringComparison.Ordinal);
    }
}