File: Publishing\BackingResourceValueResolutionTests.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.
 
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Radius.Publishing;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.Radius.Tests.Publishing;
 
/// <summary>
/// Covers how the publisher resolves container environment values: which failures it is allowed to
/// skip, which credentials it replaces, and how it escapes values destined for a URI.
/// </summary>
public class BackingResourceValueResolutionTests
{
    private static (string Bicep, RecordingLogger Logger) GenerateBicep(Action<IDistributedApplicationBuilder> configure)
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        builder.AddRadiusEnvironment("myenv");
        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 logger = new RecordingLogger();
        return (new RadiusBicepPublishingContext(radiusEnv).GenerateBicep(model, logger), logger);
    }
 
    /// <summary>
    /// A value whose provider only knows its answer after another deployment — an Azure Bicep output
    /// is the real-world case — cannot be produced while publishing, so the variable is dropped.
    /// </summary>
    /// <remarks>
    /// This pins the behaviour the publisher's narrow skip preserves. It is deliberately covered
    /// with a stand-in provider rather than a real Azure resource: the condition under test is
    /// "the value declares deployment-substituted semantics (<c>IManifestExpressionProvider</c>) and
    /// cannot produce a value now", and reproducing it through <c>Aspire.Hosting.Azure</c> would add
    /// a package reference without testing anything more. The warning matters as much as the skip —
    /// before, this was logged at Debug and so never appeared in a normal publish.
    /// </remarks>
    [Fact]
    public void ValueOnlyKnownAfterAnotherDeployment_SkipsJustThatVariable()
    {
        var (bicep, logger) = GenerateBicep(b =>
        {
            b.AddContainer("api", "myapp/api", "latest")
                .WithEnvironment(context =>
                {
                    context.EnvironmentVariables["UNRESOLVABLE"] = new ThrowingDeploymentOutput(
                        new InvalidOperationException("The output 'x' does not have a value."));
                    context.EnvironmentVariables["RESOLVABLE"] = "kept";
                });
        });
 
        Assert.DoesNotContain("UNRESOLVABLE", bicep, StringComparison.Ordinal);
        Assert.Contains("'kept'", bicep, StringComparison.Ordinal);
 
        var warnings = logger.Matching(LogLevel.Warning, "UNRESOLVABLE", "omitted from the Radius output");
        Assert.Single(warnings);
    }
 
    /// <summary>
    /// A plain value provider may use <see cref="InvalidOperationException"/> for a genuine invalid
    /// state, so it must not be mistaken for a deferred deployment output and silently dropped. Only
    /// a value that positively declares deployment-substituted semantics is eligible for the skip.
    /// </summary>
    [Fact]
    public void InvalidOperationFromAPlainValueProvider_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(b =>
        {
            b.AddContainer("api", "myapp/api", "latest")
                .WithEnvironment(context =>
                {
                    context.EnvironmentVariables["BROKEN"] = new ThrowingValueProvider(
                        new InvalidOperationException("the provider is in a genuinely invalid state"));
                });
        }));
 
        Assert.Equal("the provider is in a genuinely invalid state", ex.Message);
    }
 
    /// <summary>
    /// Any other failure is a real error and must fail the publish.
    /// </summary>
    /// <remarks>
    /// The publisher used to wrap the whole value resolution in
    /// <c>catch (InvalidOperationException)</c>, so whether a bug surfaced depended on the
    /// exception's type rather than on the publisher having judged the value unavailable. This test
    /// is the reason the skip now has a dedicated type.
    /// </remarks>
    [Fact]
    public void UnexpectedResolutionFailure_FailsThePublish()
    {
        var ex = Assert.Throws<NotSupportedException>(() => GenerateBicep(b =>
        {
            b.AddContainer("api", "myapp/api", "latest")
                .WithEnvironment(context =>
                {
                    context.EnvironmentVariables["BROKEN"] = new ThrowingValueProvider(
                        new NotSupportedException("something genuinely wrong"));
                });
        }));
 
        Assert.Equal("something genuinely wrong", ex.Message);
    }
 
    /// <summary>
    /// A reference to an endpoint the target resource does not declare is detected before anything
    /// reads the missing annotation, so it is skipped as an unavailable value rather than surfacing
    /// as an indistinguishable <see cref="InvalidOperationException"/>.
    /// </summary>
    [Fact]
    public void ReferenceToUndeclaredEndpoint_SkipsJustThatVariable()
    {
        var (bicep, logger) = GenerateBicep(b =>
        {
            var backend = b.AddContainer("backend", "myapp/backend", "latest").WithHttpEndpoint(targetPort: 8080);
 
            b.AddContainer("api", "myapp/api", "latest")
                .WithEnvironment("MISSING", backend.GetEndpoint("does-not-exist").Property(EndpointProperty.Host))
                .WithEnvironment("PRESENT", backend.GetEndpoint("http").Property(EndpointProperty.Host));
        });
 
        Assert.DoesNotContain("MISSING", bicep, StringComparison.Ordinal);
        Assert.Contains("backend-backend.default.svc.cluster.local", bicep, StringComparison.Ordinal);
        Assert.Single(logger.Matching(LogLevel.Warning, "MISSING", "is not defined on resource 'backend'"));
    }
 
    /// <summary>
    /// A user name supplied as a parameter is replaced by the one the recipe created, exactly as the
    /// password is. Without this the connection string names a user the recipe never provisioned.
    /// </summary>
    [Fact]
    public Task UserNameParameter_IsProjectedFromRecipeOutputs()
    {
        var (bicep, _) = GenerateBicep(b =>
        {
            var mongoUser = b.AddParameter("mongouser");
            var rabbitUser = b.AddParameter("rabbituser");
            var mongo = b.AddMongoDB("mongo", userName: mongoUser);
            var rabbit = b.AddRabbitMQ("rabbit", userName: rabbitUser);
 
            b.AddContainer("api", "myapp/api", "latest")
                .WithReference(mongo)
                .WithReference(rabbit);
        });
 
        return Verify(bicep, extension: "bicep");
    }
 
    /// <summary>
    /// Known gap: the default user names ("admin" for MongoDB, "guest" for RabbitMQ) are appended
    /// through <c>ReferenceExpressionBuilder.AppendFormatted(string?, string?)</c>, which formats
    /// immediately and writes the result into the format string. They therefore arrive at the
    /// publisher as opaque literal text with no value provider to substitute, and keep their default
    /// value in the emitted connection string. Pinned so the gap is visible rather than forgotten.
    /// </summary>
    [Fact]
    public void DefaultUserName_RemainsALiteral()
    {
        var (bicep, _) = GenerateBicep(b =>
        {
            var mongo = b.AddMongoDB("mongo");
            b.AddContainer("api", "myapp/api", "latest").WithReference(mongo);
        });
 
        Assert.Contains("mongodb://admin:", bicep, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// An extra database nobody references cannot produce a wrong connection string, so it must not
    /// break a model that published before.
    /// </summary>
    [Fact]
    public void UnreferencedSecondDatabase_DoesNotFailThePublish()
    {
        var (bicep, logger) = GenerateBicep(b =>
        {
            var pg = b.AddPostgres("pg");
            var used = pg.AddDatabase("used");
            pg.AddDatabase("unused");
 
            b.AddContainer("api", "myapp/api", "latest").WithReference(used);
        });
 
        Assert.Contains("'used'", bicep, StringComparison.Ordinal);
        Assert.Single(logger.Matching(LogLevel.Warning, "declares 2 databases", "'used' was passed"));
    }
 
    /// <summary>
    /// A server with no <c>AddDatabase(...)</c> child is a valid, common model, so it warns rather
    /// than failing — and the <c>database</c> property is set to the user name, because that is
    /// what a client derives from a connection string that carries no database name.
    /// </summary>
    [Fact]
    public void ServerWithNoDatabase_WarnsAndUsesTheUserNameAsTheDatabase()
    {
        var (bicep, logger) = GenerateBicep(b =>
        {
            var pg = b.AddPostgres("pg");
            b.AddContainer("api", "myapp/api", "latest").WithReference(pg);
        });
 
        Assert.Contains("pg.properties.host", bicep, StringComparison.Ordinal);
        Assert.Contains("database: 'postgres'", bicep, StringComparison.Ordinal);
        Assert.Single(logger.Matching(LogLevel.Warning, "pg", "named after the user"));
    }
 
    /// <summary>
    /// The user name a childless server is given must be the one the recipe creates the database
    /// from, so a custom user name has to reach both properties. Pinned because a mismatch here is
    /// the exact failure this fallback exists to prevent: the recipe would create <c>postgres_db</c>
    /// while the consumer opened a database named after its user.
    /// </summary>
    [Fact]
    public void ServerWithNoDatabase_UsesTheCustomUserNameAsTheDatabase()
    {
        var (bicep, _) = GenerateBicep(b =>
        {
            var userName = b.AddParameter("pguser", "appuser");
            var pg = b.AddPostgres("pg", userName: userName);
            b.AddContainer("api", "myapp/api", "latest").WithReference(pg);
        });
 
        Assert.Contains("username: pguser", bicep, StringComparison.Ordinal);
        Assert.Contains("database: pguser", bicep, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// A consumer that references the server rather than the database gets a connection string with
    /// no database name and opens the one named after the user, which is not the database the recipe
    /// creates. Run mode hides this because the Postgres image creates a database named after
    /// <c>POSTGRES_USER</c> alongside the ones Aspire creates per child; a recipe provisions one, so
    /// the reference is left pointing at a database that will not exist and has to be reported.
    /// </summary>
    [Fact]
    public void ServerReferencedInsteadOfItsDatabase_IsReportedAsPointingAtAMissingDatabase()
    {
        var (bicep, logger) = GenerateBicep(b =>
        {
            var pg = b.AddPostgres("pg");
            pg.AddDatabase("appdb");
            b.AddContainer("api", "myapp/api", "latest").WithReference(pg);
        });
 
        Assert.Contains("database: 'appdb'", bicep, StringComparison.Ordinal);
        Assert.Single(logger.Matching(LogLevel.Warning, "pg", "referenced directly"));
    }
 
    /// <summary>
    /// The counterpart to the above: a consumer that references the database itself receives that
    /// database's name in its connection string, which is exactly what the recipe creates, so there
    /// is nothing to report.
    /// </summary>
    [Fact]
    public void DatabaseReferencedRatherThanTheServer_IsNotReported()
    {
        var (bicep, logger) = GenerateBicep(b =>
        {
            var pg = b.AddPostgres("pg");
            var db = pg.AddDatabase("appdb");
            b.AddContainer("api", "myapp/api", "latest").WithReference(db);
        });
 
        Assert.Contains("database: 'appdb'", bicep, StringComparison.Ordinal);
        Assert.Empty(logger.Matching(LogLevel.Warning, "pg", "referenced directly"));
    }
 
    /// <summary>
    /// Two database children may be distinct Aspire resources that name the <em>same</em> physical
    /// database. The recipe provisions one database, and that single database satisfies both
    /// consumers, so this model must publish.
    /// </summary>
    [Fact]
    public void AliasedDatabaseChildrenNamingOnePhysicalDatabase_Publish()
    {
        var (bicep, _) = GenerateBicep(b =>
        {
            var pg = b.AddPostgres("pg");
            var a = pg.AddDatabase("orders-a", "orders");
            var bDb = pg.AddDatabase("orders-b", "orders");
 
            b.AddContainer("api", "myapp/api", "latest")
                .WithReference(a)
                .WithReference(bDb);
        });
 
        Assert.Contains("database: 'orders'", bicep, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// The same aliased model, but with an unreferenced child declared before the aliases. The
    /// referenced children still name one physical database, so this must select that database
    /// rather than the first child that happens to be declared — selecting the unreferenced one
    /// configures the recipe for a database no consumer connects to, and the deferred selection
    /// check then rejects a model that is actually supported.
    /// </summary>
    [Fact]
    public void AliasedDatabaseChildrenDeclaredAfterAnUnreferencedChild_SelectTheReferencedDatabase()
    {
        var (bicep, _) = GenerateBicep(b =>
        {
            var pg = b.AddPostgres("pg");
            pg.AddDatabase("unused");
            var a = pg.AddDatabase("orders-a", "orders");
            var bDb = pg.AddDatabase("orders-b", "orders");
 
            b.AddContainer("api", "myapp/api", "latest")
                .WithReference(a)
                .WithReference(bDb);
        });
 
        Assert.Contains("database: 'orders'", bicep, StringComparison.Ordinal);
        Assert.DoesNotContain("database: 'unused'", bicep, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// Annotations are the only reference signal available when the <c>database</c> property is
    /// chosen, and a <c>WithEnvironment</c> callback that composes a database's value inline records
    /// none. Picking the first child in that case creates a database the consumer does not use, so
    /// the publish fails instead.
    /// </summary>
    [Fact]
    public void MultipleUnreferencedDatabases_FailThePublishRatherThanGuessing()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(b =>
        {
            var pg = b.AddPostgres("pg");
            pg.AddDatabase("first");
            var second = pg.AddDatabase("second");
 
            b.AddContainer("api", "myapp/api", "latest")
                .WithEnvironment(context =>
                {
                    context.EnvironmentVariables["DB"] = second.Resource.ConnectionStringExpression;
                });
        }));
 
        Assert.Contains("ASPIRERADIUS072", ex.Message, StringComparison.Ordinal);
        Assert.Contains("none of them is referenced", ex.Message, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// The mixed case the two tests above do not cover: one database is referenced with
    /// <c>WithReference</c> while a <c>WithEnvironment</c> callback consumes a *different* one.
    /// Selection runs before any container environment is resolved, so it sees only the annotation
    /// and provisions <c>first</c> — while the callback's consumer receives a connection string
    /// naming <c>second</c>, a database the recipe never creates. The deferred validation pass is
    /// what catches this, since no annotation-only signal can.
    /// </summary>
    [Fact]
    public void ReferencedDatabaseAndCallbackConsumingAnother_FailsThePublish()
    {
        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)
                .WithEnvironment(context =>
                {
                    context.EnvironmentVariables["DB"] = second.Resource.ConnectionStringExpression;
                });
        }));
 
        Assert.Contains("ASPIRERADIUS072", ex.Message, StringComparison.Ordinal);
        // The message has to name both the database being created and the one being consumed:
        // knowing only that they disagree does not tell the author which end to change.
        Assert.Contains("'first'", ex.Message, StringComparison.Ordinal);
        Assert.Contains("'second'", ex.Message, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// The failure above exists because a <c>WithEnvironment</c> callback consumer records no
    /// reference annotation. With no consumer in the model at all there is provably no wrong
    /// connection string to protect against, so a server whose databases nobody uses keeps
    /// publishing - it did before this projection existed.
    /// </summary>
    [Fact]
    public void MultipleUnreferencedDatabasesWithNoConsumer_PublishWithAWarning()
    {
        var (bicep, logger) = GenerateBicep(b =>
        {
            var pg = b.AddPostgres("pg");
            pg.AddDatabase("first");
            pg.AddDatabase("second");
        });
 
        Assert.Contains("database: 'first'", bicep, StringComparison.Ordinal);
        Assert.Single(logger.Matching(LogLevel.Warning, "pg", "declares 2 databases"));
    }
 
    /// <summary>
    /// A Radius recipe publishes exactly one address, so every endpoint of a backing resource would
    /// otherwise project to the same host/port. That is right for the primary endpoint and silently
    /// wrong for a secondary one: the mapped <c>rabbitMQQueues</c> recipe exposes AMQP only and does
    /// not enable the management plugin, so a consumer of the <c>management</c> endpoint would be
    /// handed an HTTP URL pointing at the AMQP port.
    /// </summary>
    [Fact]
    public void SecondaryEndpointOfABackingResource_FailsThePublish()
    {
        var ex = Assert.Throws<RadiusBackingResourceProjectionException>(() => GenerateBicep(b =>
        {
            var rabbit = b.AddRabbitMQ("rabbit", userName: b.AddParameter("rabbituser")).WithManagementPlugin();
 
            b.AddContainer("api", "myapp/api", "latest")
                .WithEnvironment("MGMT_URL", rabbit.GetEndpoint("management").Property(EndpointProperty.Url));
        }));
 
        Assert.Contains("ASPIRERADIUS077", ex.Message, StringComparison.Ordinal);
        Assert.Contains("management", ex.Message, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// The primary endpoint of the same resource is still projected: the guard rejects secondary
    /// endpoints, not every endpoint of a resource that happens to declare more than one.
    /// </summary>
    [Fact]
    public void PrimaryEndpointOfABackingResourceWithSecondaryEndpoints_IsStillProjected()
    {
        var (bicep, _) = GenerateBicep(b =>
        {
            var rabbit = b.AddRabbitMQ("rabbit", userName: b.AddParameter("rabbituser")).WithManagementPlugin();
 
            b.AddContainer("api", "myapp/api", "latest")
                .WithEnvironment("AMQP_HOST", rabbit.GetEndpoint("tcp").Property(EndpointProperty.Host));
        });
 
        Assert.Contains("value: rabbit.properties.host", bicep, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// A conditional expression whose condition is a parameter with no configured value used to
    /// abort the whole publish with a raw <c>MissingParameterValueException</c>, even though the
    /// same parameter used as a <em>value</em> resolves to a <c>@secure()</c> param reference. The
    /// branch choice is baked into the emitted document, so the value genuinely cannot be produced -
    /// but it is one variable's failure, reported as such, not the publish's.
    /// </summary>
    [Fact]
    public void ConditionalWithAnUnresolvableCondition_SkipsJustThatVariable()
    {
        var (bicep, logger) = GenerateBicep(b =>
        {
            var mode = b.AddParameter("mode", secret: false);
 
            b.AddContainer("api", "myapp/api", "latest")
                .WithEnvironment("MODE_URL", ReferenceExpression.CreateConditional(
                    mode.Resource,
                    "primary",
                    ReferenceExpression.Create($"primary"),
                    ReferenceExpression.Create($"secondary")))
                .WithEnvironment("KEPT", "kept");
        });
 
        Assert.DoesNotContain("MODE_URL", bicep, StringComparison.Ordinal);
        Assert.Contains("'kept'", bicep, StringComparison.Ordinal);
 
        // The README documents the dropped variable under ASPIRERADIUS078, so the warning has to
        // name the code — otherwise the reader cannot map it back to the diagnostics table.
        var warnings = logger.Matching(LogLevel.Warning, "MODE_URL");
        Assert.Single(warnings);
        Assert.Contains("ASPIRERADIUS078", warnings[0], StringComparison.Ordinal);
    }
 
    /// <summary>
    /// The condition catch used to be blanket. Because the outer environment loop turns the
    /// translated failure into a warning and <em>drops the variable</em>, a provider bug, a network
    /// failure, or a configuration error would silently remove an environment variable from the
    /// deployed app instead of failing the publish.
    /// </summary>
    [Fact]
    public void ConditionalWhoseConditionFailsUnexpectedly_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(b =>
            b.AddContainer("api", "myapp/api", "latest")
                .WithEnvironment("MODE_URL", ReferenceExpression.CreateConditional(
                    new ThrowingValueProvider(new InvalidOperationException("provider bug")),
                    "primary",
                    ReferenceExpression.Create($"primary"),
                    ReferenceExpression.Create($"secondary")))));
 
        Assert.Equal("provider bug", ex.Message);
    }
 
    /// <summary>
    /// An <see cref="InvalidOperationException"/> is only treated as "unknowable here" when the
    /// condition resolves to a placeholder some other deployment fills in. The classification gates
    /// the skip; the exception type alone never does.
    /// </summary>
    [Fact]
    public void ConditionalWhoseManifestExpressionConditionFails_SkipsJustThatVariable()
    {
        var (bicep, logger) = GenerateBicep(b =>
            b.AddContainer("api", "myapp/api", "latest")
                .WithEnvironment("MODE_URL", ReferenceExpression.CreateConditional(
                    new ThrowingDeploymentOutput(new InvalidOperationException("not deployed yet")),
                    "primary",
                    ReferenceExpression.Create($"primary"),
                    ReferenceExpression.Create($"secondary")))
                .WithEnvironment("KEPT", "kept"));
 
        Assert.DoesNotContain("MODE_URL", bicep, StringComparison.Ordinal);
        Assert.Contains("'kept'", bicep, StringComparison.Ordinal);
        Assert.Contains("ASPIRERADIUS078", Assert.Single(logger.Matching(LogLevel.Warning, "MODE_URL")), StringComparison.Ordinal);
    }
 
    /// <summary>
    /// A <see cref="ParameterResource"/> carries <see cref="IManifestExpressionProvider"/> — every
    /// <c>IExpressionValue</c> does — so testing that marker on the condition itself would classify
    /// a parameter whose value callback genuinely fails as "not deployed yet" and drop the variable.
    /// A parameter is never deployment-substituted, so its failure must abort the publish.
    /// </summary>
    [Fact]
    public void ConditionalWhoseParameterConditionFails_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(b =>
        {
            var mode = b.AddParameter("mode", () => throw new InvalidOperationException("cannot read mode"));
 
            b.AddContainer("api", "myapp/api", "latest")
                .WithEnvironment("MODE_URL", ReferenceExpression.CreateConditional(
                    mode.Resource,
                    "primary",
                    ReferenceExpression.Create($"primary"),
                    ReferenceExpression.Create($"secondary")));
        }));
 
        Assert.Equal("cannot read mode", ex.Message);
    }
 
    /// <summary>
    /// <see cref="ReferenceExpression"/> also carries <see cref="IManifestExpressionProvider"/>, so
    /// wrapping a failing provider in one must not launder a real failure into a dropped variable.
    /// </summary>
    [Fact]
    public void ConditionalWhoseReferenceExpressionConditionFails_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(b =>
        {
            var mode = b.AddParameter("mode", () => throw new InvalidOperationException("cannot read mode"));
 
            b.AddContainer("api", "myapp/api", "latest")
                .WithEnvironment("MODE_URL", ReferenceExpression.CreateConditional(
                    ReferenceExpression.Create($"{mode.Resource}"),
                    "primary",
                    ReferenceExpression.Create($"primary"),
                    ReferenceExpression.Create($"secondary")));
        }));
 
        Assert.Equal("cannot read mode", ex.Message);
    }
 
    /// <summary>
    /// The classification walks into a wrapper, so a deployment output reached through a
    /// <see cref="ReferenceExpression"/> is still recognised as unknowable while publishing. Without
    /// this the tightened check would break the composite conditions authors actually write.
    /// </summary>
    [Fact]
    public void ConditionalWrappingAManifestExpressionCondition_SkipsJustThatVariable()
    {
        var (bicep, logger) = GenerateBicep(b =>
            b.AddContainer("api", "myapp/api", "latest")
                .WithEnvironment("MODE_URL", ReferenceExpression.CreateConditional(
                    ReferenceExpression.Create($"{new ThrowingDeploymentOutput(new InvalidOperationException("not deployed yet"))}"),
                    "primary",
                    ReferenceExpression.Create($"primary"),
                    ReferenceExpression.Create($"secondary")))
                .WithEnvironment("KEPT", "kept"));
 
        Assert.DoesNotContain("MODE_URL", bicep, StringComparison.Ordinal);
        Assert.Contains("'kept'", bicep, StringComparison.Ordinal);
        Assert.Contains("ASPIRERADIUS078", Assert.Single(logger.Matching(LogLevel.Warning, "MODE_URL")), StringComparison.Ordinal);
    }
 
    /// <summary>
    /// <see cref="ContainerImageReference"/> carries <see cref="IManifestExpressionProvider"/> but
    /// resolves eagerly against the running app's services, so its failure names a real
    /// configuration error rather than a value another deployment supplies. Classifying by the
    /// marker alone would drop the variable instead of reporting it.
    /// </summary>
    [Fact]
    public void ConditionalWhoseContainerImageConditionFails_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(b =>
        {
            var target = b.AddContainer("worker", "myapp/worker", "latest");
 
            b.AddContainer("api", "myapp/api", "latest")
                .WithEnvironment("MODE_URL", ReferenceExpression.CreateConditional(
                    new ContainerImageReference(target.Resource),
                    "primary",
                    ReferenceExpression.Create($"primary"),
                    ReferenceExpression.Create($"secondary")));
        }));
 
        // The failure is reported as itself rather than translated into ASPIRERADIUS078, which is
        // the whole point: a RadiusUnresolvableValueException here would become a warning and take
        // the variable with it.
        Assert.IsNotType<RadiusUnresolvableValueException>(ex);
    }
 
    /// <summary>
    /// A resource whose connection string refers back to itself makes the classification walk cycle.
    /// Revisiting a node answers "not deployment-substituted" so the original failure propagates —
    /// an incomplete traversal must never be the reason a variable is dropped.
    /// </summary>
    [Fact]
    public void ConditionalWhoseSelfReferentialConnectionStringConditionFails_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(b =>
        {
            var mode = b.AddParameter("mode", () => throw new InvalidOperationException("cannot read mode"));
            var cyclic = new SelfReferentialConnectionStringResource("cyclic", mode.Resource);
 
            b.AddContainer("api", "myapp/api", "latest")
                .WithEnvironment("MODE_URL", ReferenceExpression.CreateConditional(
                    cyclic.ConnectionStringExpression,
                    "primary",
                    ReferenceExpression.Create($"primary"),
                    ReferenceExpression.Create($"secondary")));
        }));
 
        Assert.Equal("cannot read mode", ex.Message);
    }
 
    /// <summary>
    /// A conditional expression's <c>ValueProviders</c> is the union of its two <em>branches</em> and
    /// excludes its own condition, so a nested conditional hides the provider that decides the
    /// classification. Walking only <c>ValueProviders</c> would classify this as a real failure and
    /// abort a publish that should merely drop the variable.
    /// </summary>
    [Fact]
    public void ConditionalWhoseNestedConditionalConditionIsAManifestExpression_SkipsJustThatVariable()
    {
        var (bicep, logger) = GenerateBicep(b =>
            b.AddContainer("api", "myapp/api", "latest")
                .WithEnvironment("MODE_URL", ReferenceExpression.CreateConditional(
                    ReferenceExpression.CreateConditional(
                        new ThrowingDeploymentOutput(new InvalidOperationException("not deployed yet")),
                        "on",
                        ReferenceExpression.Create($"primary"),
                        ReferenceExpression.Create($"secondary")),
                    "primary",
                    ReferenceExpression.Create($"primary"),
                    ReferenceExpression.Create($"secondary")))
                .WithEnvironment("KEPT", "kept"));
 
        Assert.DoesNotContain("MODE_URL", bicep, StringComparison.Ordinal);
        Assert.Contains("'kept'", bicep, StringComparison.Ordinal);
        Assert.Contains("ASPIRERADIUS078", Assert.Single(logger.Matching(LogLevel.Warning, "MODE_URL")), StringComparison.Ordinal);
    }
 
    /// <summary>
    /// Substitutions are keyed by parameter identity, so a parameter given as both the user name and
    /// the password of a recipe-provisioned resource can only be rewritten to one of the two
    /// recipe-generated values. It used to be silently rewritten to the user name, handing consumers
    /// <c>properties.username</c> wherever they asked for the password.
    /// </summary>
    [Fact]
    public void ParameterSharedBetweenUserNameAndPassword_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(b =>
        {
            var shared = b.AddParameter("shared", "value", secret: true);
            var mongo = b.AddMongoDB("mongo", userName: shared, password: shared);
 
            b.AddContainer("api", "myapp/api", "latest").WithReference(mongo);
        }));
 
        Assert.Contains("ASPIRERADIUS070", ex.Message, StringComparison.Ordinal);
        Assert.Contains("both the user name and the password", ex.Message, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// A user-name parameter shared with a resource whose credential is substituted from
    /// <c>listSecrets()</c> is just as unsafe as a shared password: this resource keeps the
    /// parameter's own value while every consumer reference is rewritten to the other resource's
    /// recipe secret. It is only caught if user names are registered as recipe credentials too.
    /// </summary>
    [Fact]
    public void UserNameParameterSharedWithASubstitutedCredential_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(b =>
        {
            var shared = b.AddParameter("shared", "value", secret: true);
            var pg = b.AddPostgres("pg", userName: shared);
            var sql = b.AddSqlServer("sql", password: shared);
 
            b.AddContainer("api", "myapp/api", "latest")
                .WithReference(pg)
                .WithReference(sql);
        }));
 
        Assert.Contains("ASPIRERADIUS070", ex.Message, StringComparison.Ordinal);
        Assert.Contains("shared", ex.Message, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// Replacing a password Aspire generated for run mode is invisible and correct. Replacing one
    /// the AppHost author chose is a decision being overridden, so it has to be reported — otherwise
    /// they debug a credential mismatch against a value that never reached the cluster.
    /// </summary>
    /// <remarks>
    /// Based on MongoDB rather than RabbitMQ: RabbitMQ moved onto its Radius.Messaging UDT in
    /// Radius 0.60, where the password Aspire supplies is carried into the deployment through a
    /// Radius.Security/secrets resource instead of being replaced by a recipe-generated one. See
    /// <see cref="UserSuppliedRabbitMqPassword_IsCarriedIntoASecretRatherThanReplaced"/>. MongoDB
    /// still emits the legacy portable type and its listSecrets() credential.
    /// </remarks>
    [Fact]
    public void UserSuppliedPassword_IsReportedWhenReplacedByTheRecipeSecret()
    {
        var (_, logger) = GenerateBicep(b =>
        {
            var password = b.AddParameter("mongopassword", "hunter2", secret: true);
            var mongo = b.AddMongoDB("mongo", password: password);
            b.AddContainer("api", "myapp/api", "latest").WithReference(mongo);
        });
 
        Assert.Single(logger.Matching(LogLevel.Warning, "mongopassword", "is not used when deploying"));
    }
 
    /// <summary>
    /// Radius.Security/secrets is recipe-backed like every other type, so emitting one obliges the
    /// recipe pack to carry its recipe too. Without that entry the deploy fails resolving a recipe
    /// for the secret — a failure that names the secret, not the resource that pulled it in.
    /// </summary>
    /// <remarks>
    /// The official pack registers it the same way, see `recipe-packs/kubernetes/default-recipepack.bicep`
    /// in radius-project/resource-types-contrib.
    /// </remarks>
    [Fact]
    public void EmittingACredentialSecret_AlsoRegistersTheSecretsRecipe()
    {
        var (bicep, _) = GenerateBicep(b =>
        {
            var user = b.AddParameter("rabbituser");
            var rabbit = b.AddRabbitMQ("rabbit", userName: user);
            b.AddContainer("api", "myapp/api", "latest").WithReference(rabbit);
        });
 
        Assert.Contains("'Radius.Security/secrets': {", bicep, StringComparison.Ordinal);
        Assert.Contains("ghcr.io/radius-project/kube-recipes/secrets:latest", bicep, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// RabbitMQ restricts the `guest` account to loopback connections, so a broker provisioned with
    /// it rejects every workload Pod. Aspire cannot silently substitute a different name either,
    /// because the default user name is baked into the connection string as literal text. So the
    /// publish fails and names the fix rather than deploying a broker nothing can connect to.
    /// </summary>
    [Fact]
    public void DefaultRabbitMqUserName_FailsThePublishBecauseGuestIsLoopbackOnly()
    {
        var ex = Assert.Throws<RadiusBackingResourceProjectionException>(() => GenerateBicep(b =>
        {
            var rabbit = b.AddRabbitMQ("rabbit");
            b.AddContainer("api", "myapp/api", "latest").WithReference(rabbit);
        }));
 
        Assert.Contains("ASPIRERADIUS082", ex.Message, StringComparison.Ordinal);
        Assert.Contains("guest", ex.Message, StringComparison.Ordinal);
        Assert.Contains("userName:", ex.Message, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// The same guard has to hold when `guest` arrives through a parameter. A parameter renders as
    /// a Bicep identifier rather than as its value, so the emitted text alone cannot distinguish it
    /// from any other user name — and supplying a parameter is precisely the remediation
    /// ASPIRERADIUS082 recommends, so a value-blind check would route users around the guard using
    /// the guard's own advice.
    /// </summary>
    [Fact]
    public void ParameterSuppliedGuestRabbitMqUserName_AlsoFailsThePublish()
    {
        var ex = Assert.Throws<RadiusBackingResourceProjectionException>(() => GenerateBicep(b =>
        {
            var rabbit = b.AddRabbitMQ("rabbit", userName: b.AddParameter("rabbituser", "guest"));
            b.AddContainer("api", "myapp/api", "latest").WithReference(rabbit);
        }));
 
        Assert.Contains("ASPIRERADIUS082", ex.Message, StringComparison.Ordinal);
        Assert.Contains("guest", ex.Message, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// The complement of <see cref="ParameterSuppliedGuestRabbitMqUserName_AlsoFailsThePublish"/>:
    /// resolving the parameter's value must not turn every parameter-supplied user name into a
    /// failure. Only `guest` is rejected.
    /// </summary>
    [Fact]
    public void ParameterSuppliedNonGuestRabbitMqUserName_PublishesSuccessfully()
    {
        var (bicep, _) = GenerateBicep(b =>
        {
            var rabbit = b.AddRabbitMQ("rabbit", userName: b.AddParameter("rabbituser", "appuser"));
            b.AddContainer("api", "myapp/api", "latest").WithReference(rabbit);
        });
 
        Assert.Contains("Radius.Messaging/rabbitMQ@", bicep, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// The RabbitMQ UDT takes its password as the resource ID of a Radius.Security/secrets resource,
    /// so — unlike the listSecrets() types — the password the AppHost author chose is the one the
    /// recipe provisions. Nothing is overridden, so nothing is reported.
    /// </summary>
    [Fact]
    public void UserSuppliedRabbitMqPassword_IsCarriedIntoASecretRatherThanReplaced()
    {
        var (bicep, logger) = GenerateBicep(b =>
        {
            var password = b.AddParameter("rabbitpassword", "hunter2", secret: true);
            var rabbit = b.AddRabbitMQ("rabbit", userName: b.AddParameter("rabbituser"), password: password);
            b.AddContainer("api", "myapp/api", "latest").WithReference(rabbit);
        });
 
        Assert.Empty(logger.Matching(LogLevel.Warning, "rabbitpassword", "is not used when deploying"));
 
        // The credential travels as a secret resource the broker references by ID, never as a
        // literal on the broker itself.
        Assert.Contains("Radius.Security/secrets", bicep, StringComparison.Ordinal);
        Assert.Contains("password: rabbit_password_secret.id", bicep, StringComparison.Ordinal);
 
        // The value is a reference to the valueless @secure() param, so no credential is written
        // into the published artifact.
        Assert.Contains("value: rabbitpassword", bicep, StringComparison.Ordinal);
        Assert.DoesNotContain("hunter2", bicep, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// Redis is the one mapped type whose recipe deploys an <em>unauthenticated</em> server: the
    /// pinned <c>kube-recipes/rediscaches</c> recipe starts a bare <c>redis</c> image with no
    /// <c>--requirepass</c> and publishes only <c>host</c>/<c>port</c>. There is no deployed
    /// credential to project, so it resolves to the empty value the deployed server actually has -
    /// and says so.
    /// </summary>
    [Fact]
    public void RedisPasswordIsNotProjected_BecauseTheRecipeDeploysAnUnauthenticatedServer()
    {
        var (bicep, logger) = GenerateBicep(b =>
        {
            // The generated password, not an explicit one: an explicitly supplied password is a
            // request for an authenticated deployment this recipe cannot satisfy, so it fails the
            // publish instead (ASPIRERADIUS085, pinned below).
            var cache = b.AddRedis("cache");
            b.AddContainer("api", "myapp/api", "latest").WithReference(cache);
        });
 
        Assert.Single(logger.Matching(LogLevel.Warning, "cache", "ASPIRERADIUS075"));
 
        // Neither the parameter nor a listSecrets() call for a secret the recipe never records.
        Assert.DoesNotContain("cache-password", bicep, StringComparison.Ordinal);
        Assert.DoesNotContain("cache.listSecrets()", bicep, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// A password the AppHost author supplied for a resource whose recipe provisions no credential
    /// is a request for an authenticated deployment that cannot be honoured. Discarding it behind a
    /// warning would deploy an unauthenticated server while the author believed otherwise, so it
    /// fails the publish and names the choice.
    /// </summary>
    [Fact]
    public void ExplicitPasswordOnAnUnauthenticatedRecipe_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(b =>
        {
            var password = b.AddParameter("cachepassword", "hunter2", secret: true);
            var cache = b.AddRedis("cache", password: password);
            b.AddContainer("api", "myapp/api", "latest").WithReference(cache);
        }));
 
        Assert.Contains("ASPIRERADIUS085", ex.Message, StringComparison.Ordinal);
        Assert.Contains("cachepassword", ex.Message, StringComparison.Ordinal);
        Assert.Contains("cache", ex.Message, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// A password Aspire generated has no deploy-time meaning, so replacing it is not worth a
    /// warning. Pinned so the warning above cannot become noise on every model.
    /// </summary>
    [Fact]
    public void GeneratedPassword_IsReplacedSilently()
    {
        var (_, logger) = GenerateBicep(b =>
        {
            var cache = b.AddRedis("cache");
            b.AddContainer("api", "myapp/api", "latest").WithReference(cache);
        });
 
        Assert.Empty(logger.Matching(LogLevel.Warning, "is not used when deploying"));
    }
 
    /// <summary>
    /// A parameter that was substituted for a recipe-generated secret is rewritten everywhere it
    /// appears, so an unrelated consumer of the same parameter silently receives another resource's
    /// credential. The intent is genuinely ambiguous there, so it is reported rather than rejected.
    /// </summary>
    [Fact]
    public void SubstitutedParameterUsedElsewhere_IsReported()
    {
        var (_, logger) = GenerateBicep(b =>
        {
            var shared = b.AddParameter("shared", "hunter2", secret: true);
            var sql = b.AddSqlServer("sql", password: shared);
 
            b.AddContainer("api", "myapp/api", "latest")
                .WithReference(sql)
                .WithEnvironment("ADMIN_PASSWORD", shared);
        });
 
        Assert.Single(logger.Matching(LogLevel.Warning, "references parameter 'shared'", "credential of 'sql'"));
    }
 
    /// <summary>
    /// The same ambiguity, expressed through a <c>WithEnvironment</c> callback. This form records no
    /// <c>ResourceRelationshipAnnotation</c> at all, so the detection has to run where the
    /// substitution is actually applied rather than over the annotation graph.
    /// </summary>
    [Fact]
    public void SubstitutedParameterUsedInsideAnEnvironmentCallback_IsReported()
    {
        var (_, logger) = GenerateBicep(b =>
        {
            var shared = b.AddParameter("shared", "hunter2", secret: true);
            var sql = b.AddSqlServer("sql", password: shared);
 
            b.AddContainer("api", "myapp/api", "latest")
                .WithReference(sql)
                .WithEnvironment(context =>
                {
                    context.EnvironmentVariables["ADMIN_PASSWORD"] = shared;
                });
        });
 
        var warnings = logger.Matching(LogLevel.Warning, "references parameter 'shared'", "credential of 'sql'");
        Assert.Single(warnings);
 
        // The README documents this warning under ASPIRERADIUS070, so the message has to name the
        // code — otherwise the reader cannot map it back to the diagnostics table.
        Assert.Contains("ASPIRERADIUS070", warnings[0], StringComparison.Ordinal);
    }
 
    /// <summary>
    /// A TLS-enabled endpoint describes the container Aspire would have run locally; the recipe decides
    /// what the deployed workload speaks, and <c>local-dev/rediscaches</c> starts plain Redis. So
    /// the scheme-bearing values would tell the consumer <c>rediss://</c> and <c>ssl=true</c> about
    /// a plaintext server - a run-time handshake failure pointing at neither side. There is no value
    /// here that becomes right at deploy time, so it fails the publish instead.
    /// </summary>
    [Fact]
    public void TlsEnabledBackingEndpoint_FailsThePublish()
    {
        var ex = Assert.Throws<RadiusBackingResourceProjectionException>(() => GenerateBicep(b =>
        {
            var cache = b.AddRedis("cache").WithEndpoint("tcp", e => e.TlsEnabled = true);
 
            b.AddContainer("api", "myapp/api", "latest")
                .WithEnvironment("CACHE_URL", cache.GetEndpoint("tcp").Property(EndpointProperty.Url));
        }));
 
        Assert.Contains("ASPIRERADIUS081", ex.Message, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// Only the properties carrying the transport decision are rejected. The address is correct
    /// whatever the transport, so a consumer reading host and port still connects to the right
    /// place - pinned so the check above cannot widen into rejecting every TLS-enabled model.
    /// </summary>
    [Fact]
    public void AddressPropertiesOfATlsEnabledBackingEndpoint_AreStillProjected()
    {
        var (bicep, _) = GenerateBicep(b =>
        {
            var cache = b.AddRedis("cache").WithEndpoint("tcp", e => e.TlsEnabled = true);
 
            b.AddContainer("api", "myapp/api", "latest")
                .WithEnvironment("CACHE_HOST", cache.GetEndpoint("tcp").Property(EndpointProperty.Host))
                .WithEnvironment("CACHE_PORT", cache.GetEndpoint("tcp").Property(EndpointProperty.Port));
        });
 
        Assert.Contains("cache.properties.host", bicep, StringComparison.Ordinal);
        Assert.Contains("cache.properties.port", bicep, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// The value of an injected connection property is not proof that the injection produced it: an
    /// AppHost author can build the identical <see cref="ReferenceExpression"/> themselves, because
    /// <c>ReferenceExpression.Create($"{shared}")</c> renders to the same manifest expression as the
    /// owner's <c>password</c> property when <c>shared</c> is that parameter. The variable is the
    /// author's own, its value is replaced by the recipe credential all the same, and that is
    /// precisely what ASPIRERADIUS070 exists to report - so provenance is taken from the name the
    /// splat would have produced for a declared reference, not from the value alone.
    /// </summary>
    [Fact]
    public void UserAuthoredValueMatchingAnInjectedProperty_IsStillReportedAsAnUnrelatedUse()
    {
        var (_, logger) = GenerateBicep(b =>
        {
            var shared = b.AddParameter("shared", "hunter2", secret: true);
            var sql = b.AddSqlServer("sql", password: shared);
 
            b.AddContainer("api", "myapp/api", "latest")
                .WithReference(sql)
                .WithEnvironment("ADMIN_PASSWORD", ReferenceExpression.Create($"{shared}"));
        });
 
        Assert.Single(logger.Matching(LogLevel.Warning, "references parameter 'shared'", "credential of 'sql'"));
    }
 
    /// <summary>
    /// The logical connection name recorded by <c>WithReference</c> identifies an aliased property's
    /// prefix, so the genuine injection stays silent while an unrelated author-owned variable does not.
    /// </summary>
    [Fact]
    public void AliasedReferenceToTheOwningResource_IsNotReportedAsAnUnrelatedUse()
    {
        var (_, logger) = GenerateBicep(b =>
        {
            var shared = b.AddParameter("shared", "hunter2", secret: true);
            var sql = b.AddSqlServer("sql", password: shared);
 
            b.AddContainer("api", "myapp/api", "latest").WithReference(sql, "admin");
        });
 
        Assert.Empty(logger.Matching(LogLevel.Warning, "references parameter"));
    }
 
    [Fact]
    public Task NestedConnectionName_PreservesLogicalPropertyPrefixes()
    {
        var (bicep, logger) = GenerateBicep(b =>
        {
            var shared = b.AddParameter("shared", "hunter2", secret: true);
            var sql = b.AddSqlServer("sql", password: shared);
 
            b.AddContainer("api", "myapp/api", "1.0").WithReference(sql, "db__primary");
        });
 
        Assert.Equal(
            ["The password parameter 'shared' supplied for 'sql' is not used when deploying " +
             "to Radius. The recipe that provisions that resource generates its own credentials, and consumers are given " +
             "those instead. Remove the parameter, or provision the resource yourself if the value must be fixed."],
            logger.Entries.Where(e => e.Level == LogLevel.Warning).Select(e => e.Message));
 
        return Verify(bicep, extension: "bicep");
    }
 
    [Fact]
    public void NestedConnectionName_WithOverriddenOriginalAlias_PreservesLogicalPropertyPrefixes()
    {
        var (_, logger) = GenerateBicep(b =>
        {
            var shared = b.AddParameter("shared", "hunter2", secret: true);
            var sql = b.AddSqlServer("sql", password: shared);
 
            b.AddContainer("api", "myapp/api", "1.0")
                .WithReference(sql, "db__primary")
                .WithEnvironment("ConnectionStrings__db__primary", "Host=override");
        });
 
        Assert.Equal(
            ["The password parameter 'shared' supplied for 'sql' is not used when deploying " +
             "to Radius. The recipe that provisions that resource generates its own credentials, and consumers are given " +
             "those instead. Remove the parameter, or provision the resource yourself if the value must be fixed."],
            logger.Entries.Where(e => e.Level == LogLevel.Warning).Select(e => e.Message));
    }
 
    [Fact]
    public Task NestedConnectionName_UserAuthoredPortablePrefix_IsReportedAsAnUnrelatedUse()
    {
        var (bicep, logger) = GenerateBicep(b =>
        {
            var shared = b.AddParameter("shared", "hunter2", secret: true);
            var sql = b.AddSqlServer("sql", password: shared);
 
            // Exclude the splat so a warning on an injected property cannot mask a missed warning
            // on the user-authored value: Radius deduplicates warnings by resource and parameter.
            b.AddContainer("api", "myapp/api", "1.0")
                .WithReferenceEnvironment(ReferenceEnvironmentInjectionFlags.ConnectionString)
                .WithReference(sql, "db__primary")
                .WithEnvironment("DB_PRIMARY_PASSWORD", ReferenceExpression.Create($"{shared}"));
        });
 
        Assert.Equal(
            "Resource 'api' references parameter 'shared', which is also the credential of " +
            "'sql'. That resource is provisioned by a Radius recipe which generates its own credential, " +
            "so the referencing resource receives the recipe's value rather than the parameter's. Use a separate " +
            "parameter if that is not intended. Diagnostic: ASPIRERADIUS070.",
            Assert.Single(logger.Matching(LogLevel.Warning, "references parameter")));
 
        return Verify(bicep, extension: "bicep");
    }
 
    /// <summary>
    /// A consumer that only takes the backing resource's connection string reaches the very same
    /// substituted parameter, but through the owner's own connection string — which is exactly what
    /// the substitution is for. Pinned so the detection cannot regress into warning on every
    /// <c>WithReference</c>.
    /// </summary>
    [Fact]
    public void ConsumerOfTheOwningResource_IsNotReportedAsAnUnrelatedUse()
    {
        var (_, logger) = GenerateBicep(b =>
        {
            var password = b.AddParameter("sqlpassword", "hunter2", secret: true);
            var sql = b.AddSqlServer("sql", password: password);
 
            b.AddContainer("api", "myapp/api", "latest").WithReference(sql);
        });
 
        Assert.Empty(logger.Matching(LogLevel.Warning, "references parameter"));
    }
 
    /// <summary>
    /// The <c>RecipeInputProperties</c> types write both credentials straight onto the resource, so
    /// neither registration is a projection substitution and
    /// <c>RegisterRecipeCredential</c>'s cross-owner check never fires. One parameter published as
    /// both the user name and the password is still never what the AppHost meant, and the README
    /// documents the restriction without qualifying it by type, so it has to fail here too.
    /// </summary>
    [Fact]
    public void ParameterSharedBetweenUserNameAndPasswordOfARecipeInputResource_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(b =>
        {
            var shared = b.AddParameter("shared", "value", secret: true);
            var pg = b.AddPostgres("pg", userName: shared, password: shared);
            pg.AddDatabase("pgdb");
 
            b.AddContainer("api", "myapp/api", "latest").WithReference(pg);
        }));
 
        Assert.Contains("ASPIRERADIUS070", ex.Message, StringComparison.Ordinal);
        Assert.Contains("both the user name and the password", ex.Message, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// Values a <c>ReferenceExpression</c> declared with the <c>uri</c> format must be
    /// percent-encoded in the emitted Bicep. Aspire escapes them with <c>Uri.EscapeDataString</c>
    /// when it resolves such a value itself, but the publisher emits an expression whose value is
    /// only known at deploy time, so the escaping has to be emitted as a <c>uriComponent(...)</c>
    /// call. It matters more since the fix: the password now comes from an alphabet the recipe
    /// chooses rather than Aspire's URL-safe generated one, so an unescaped <c>@</c> or <c>/</c>
    /// would corrupt the URI.
    /// </summary>
    [Fact]
    public void UriFormattedValues_AreEscapedInTheEmittedBicep()
    {
        var (bicep, _) = GenerateBicep(b =>
        {
            // MongoDB rather than Redis: Redis maps to a recipe that deploys an unauthenticated
            // server, so it has no listSecrets() credential to escape.
            var mongo = b.AddMongoDB("mongo");
            var pgdb = b.AddPostgres("pg").AddDatabase("pgdb");
 
            b.AddContainer("api", "myapp/api", "latest")
                .WithReference(mongo)
                .WithReference(pgdb);
        });
 
        // The recipe-generated secret (listSecrets) and the recipe-input parameter both need it.
        Assert.Contains("uriComponent(mongo.listSecrets().password)", bicep, StringComparison.Ordinal);
        Assert.Contains("uriComponent(pg_password)", bicep, StringComparison.Ordinal);
 
        // Non-URI values are untouched: escaping a connection-string password would corrupt it.
        Assert.Contains("value: mongo.listSecrets().password", bicep, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// Radius types the port output as an int, and Bicep's <c>uriComponent()</c> type-checks its
    /// argument eagerly rather than coercing it the way string interpolation does. A
    /// <c>uri</c>-formatted port inside a composite expression therefore has to be wrapped in
    /// <c>string(...)</c> first, exactly as the lone-projection path already does.
    /// </summary>
    [Fact]
    public void UriFormattedNumericProjection_IsConvertedToAStringFirst()
    {
        var (bicep, _) = GenerateBicep(b =>
        {
            var cache = b.AddRedis("cache");
            var port = cache.GetEndpoint("tcp").Property(EndpointProperty.Port);
 
            b.AddContainer("api", "myapp/api", "latest")
                .WithReference(cache)
                .WithEnvironment("PORT_URL", ReferenceExpression.Create($"tcp://cache:{port:uri}/"));
        });
 
        Assert.Contains("uriComponent(string(cache.properties.port))", bicep, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// A conditional <c>ReferenceExpression</c> deliberately carries an empty <c>Format</c> and
    /// exposes the union of both branches' value providers, so the ordinary splice would resolve
    /// both branches and then emit nothing at all. The branch has to be selected first, as
    /// <c>ReferenceExpression.GetValueAsync</c> and <c>ExpressionResolver</c> both do.
    /// </summary>
    [Fact]
    public void ConditionalExpression_EmitsOnlyTheSelectedBranch()
    {
        var (bicep, _) = GenerateBicep(b =>
        {
            var mode = b.AddParameter("mode", "primary");
            var cache = b.AddRedis("cache");
 
            var conditional = ReferenceExpression.CreateConditional(
                mode.Resource,
                "primary",
                ReferenceExpression.Create($"primary-{cache.GetEndpoint("tcp").Property(EndpointProperty.Host)}"),
                ReferenceExpression.Create($"secondary-fallback"));
 
            b.AddContainer("api", "myapp/api", "latest")
                .WithReference(cache)
                .WithEnvironment("MODE_URL", conditional);
        });
 
        Assert.Contains("'primary-${cache.properties.host}'", bicep, StringComparison.Ordinal);
        Assert.DoesNotContain("secondary-fallback", bicep, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// A non-HTTP endpoint declared without a port is given an allocated one, so a consumer that
    /// references it still receives a complete address and no environment variable is dropped.
    /// </summary>
    /// <remarks>
    /// Guards the narrowed <c>catch (RadiusUnresolvableValueException)</c> in the container
    /// environment loop. The concern is that a portless <c>tcp</c> endpoint would reach
    /// <c>GetDefaultPort</c>, which throws a plain <see cref="InvalidOperationException"/> for a
    /// non-HTTP scheme and would now abort the publish. It cannot: the Radius override consults
    /// <c>RadiusServiceDiscovery.ResolveServicePort</c> first, which delegates to
    /// <c>ResourceExtensions.ResolveEndpoints</c>, whose fallback arm allocates a port
    /// (<c>ResolvedPort.Allocated(portAllocator.AllocatePort())</c>). <c>ResolveServicePort</c>
    /// returns <see langword="null"/> only for a project's synthetic default HTTPS endpoint, whose
    /// scheme <c>GetDefaultPort</c> answers with 443. This test pins that so the narrowing cannot
    /// regress into a publish failure.
    /// </remarks>
    [Fact]
    public void PortlessNonHttpEndpoint_IsResolvedRatherThanOmitted()
    {
        var (bicep, logger) = GenerateBicep(b =>
        {
            var backend = b.AddContainer("backend", "myapp/backend", "latest")
                .WithEndpoint(scheme: "tcp", name: "custom");
 
            b.AddContainer("api", "myapp/api", "latest")
                .WithEnvironment("BACKEND_URL", backend.GetEndpoint("custom"));
        });
 
        Assert.Contains("BACKEND_URL", bicep, StringComparison.Ordinal);
        Assert.Empty(logger.Matching(LogLevel.Warning, "omitted from the Radius output"));
    }
 
    /// <summary>
    /// The legacy SQL recipe starts a SQL Server but never creates a database, while
    /// <c>AddDatabase(...)</c> is composed into the consumer's connection string. That mismatch is
    /// reported so it is not discovered as a connection failure after deployment.
    /// </summary>
    [Fact]
    public void ReferencedSqlDatabase_IsReportedAsNotCreatedByTheRecipe()
    {
        var (_, logger) = GenerateBicep(b =>
        {
            var db = b.AddSqlServer("sql").AddDatabase("appdb");
            b.AddContainer("api", "myapp/api", "latest").WithReference(db);
        });
 
        // The diagnostic code is pinned alongside the prose so a renumbering is caught here rather
        // than silently invalidating the README table.
        Assert.Single(logger.Matching(LogLevel.Warning, "sql", "does not create databases", "appdb", "ASPIRERADIUS080"));
        // The database's connection string and its splatted properties both carry the server's
        // credential, which is legitimate provenance rather than an unrelated use, so ASPIRERADIUS070
        // must stay silent here — otherwise every ordinary `AddDatabase(...)` reference reports it.
        Assert.Empty(logger.Matching(LogLevel.Warning, "ASPIRERADIUS070"));
    }
 
    /// <summary>
    /// A <c>WithEnvironment</c> callback that composes the database's connection string inline
    /// records no reference annotation, so the consumer is invisible to the annotation scan. The
    /// warning is driven off the connection strings actually resolved as well, so this consumer is
    /// still told the database will not exist.
    /// </summary>
    [Fact]
    public void CallbackOnlySqlDatabaseConsumer_IsReportedAsNotCreatedByTheRecipe()
    {
        var (_, logger) = GenerateBicep(b =>
        {
            var db = b.AddSqlServer("sql").AddDatabase("appdb");
            b.AddContainer("api", "myapp/api", "latest")
                .WithEnvironment(ctx => ctx.EnvironmentVariables["CS"] = db.Resource);
        });
 
        Assert.Single(logger.Matching(LogLevel.Warning, "sql", "does not create databases", "appdb", "ASPIRERADIUS080"));
    }
 
    /// <summary>
    /// A callback that assigns the child's <c>ConnectionStringExpression</c> directly, rather than
    /// the child resource, records no reference annotation and puts no child node in the resolved
    /// value. The expression is matched against the child's own so this consumer is still told the
    /// database will not exist.
    /// </summary>
    [Fact]
    public void CallbackAssigningSqlDatabaseConnectionStringExpression_IsReportedAsNotCreatedByTheRecipe()
    {
        var (_, logger) = GenerateBicep(b =>
        {
            var db = b.AddSqlServer("sql").AddDatabase("appdb");
            b.AddContainer("api", "myapp/api", "latest")
                .WithEnvironment(ctx => ctx.EnvironmentVariables["CS"] = db.Resource.ConnectionStringExpression);
        });
 
        Assert.Single(logger.Matching(LogLevel.Warning, "sql", "does not create databases", "appdb", "ASPIRERADIUS080"));
    }
 
    /// <summary>
    /// An unreferenced <c>AddDatabase(...)</c> produces no consumer connection string, so it cannot
    /// mislead anyone. Pinned so the warning above cannot become noise on every model that declares
    /// a database it does not use.
    /// </summary>
    [Fact]
    public void UnreferencedSqlDatabase_IsNotReported()
    {
        var (_, logger) = GenerateBicep(b =>
        {
            var sql = b.AddSqlServer("sql");
            sql.AddDatabase("appdb");
            b.AddContainer("api", "myapp/api", "latest").WithReference(sql);
        });
 
        Assert.Empty(logger.Matching(LogLevel.Warning, "does not create databases"));
    }
 
    /// <summary>
    /// Aspire's <c>ReferenceExpression</c> validates a format string only when it resolves the
    /// value, and the publisher never calls <c>GetValueAsync</c> — it compiles the expression into
    /// Bicep instead. An unsupported format therefore reaches the publisher intact, and there is no
    /// Bicep equivalent to emit for it, so it fails the publish rather than silently dropping the
    /// formatting and emitting a value that is not escaped the way the author asked.
    /// </summary>
    [Fact]
    public void UnsupportedStringFormat_FailsThePublish()
    {
        var ex = Assert.Throws<NotSupportedException>(() => GenerateBicep(b =>
        {
            var token = b.AddParameter("token", "abc", secret: true);
 
            b.AddContainer("api", "myapp/api", "latest")
                .WithEnvironment("TOKEN", ReferenceExpression.Create($"{token:json}"));
        }));
 
        Assert.Contains("ASPIRERADIUS073", ex.Message, StringComparison.Ordinal);
        Assert.Contains("json", ex.Message, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// The <c>Radius.*</c> manifests mark <c>username</c> and <c>password</c> <c>required</c>, so a
    /// resource that exposes neither as a connection property would publish an artifact Radius
    /// rejects during schema validation — with nothing at publish time to explain it. Asserted
    /// against a resource that maps to the Postgres type but publishes no connection properties,
    /// because every shipped resource that maps there exposes both.
    /// </summary>
    [Fact]
    public void BackingResourceMissingARequiredSchemaProperty_FailsThePublish()
    {
        var ex = Assert.Throws<RadiusBackingResourceProjectionException>(() => GenerateBicep(b =>
        {
            var pg = b.AddResource(new PostgresServerResource("pg", [])).WithImage("postgres", "17");
            b.AddContainer("api", "myapp/api", "latest").WithReference(pg);
        }));
 
        Assert.Contains("ASPIRERADIUS076", ex.Message, StringComparison.Ordinal);
        Assert.Contains("pg", ex.Message, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// A connection property that cannot be resolved at publish time fails the publish, unlike the
    /// same unresolvable value in a container environment variable, which is skipped.
    /// </summary>
    /// <remarks>
    /// This is the opposite half of <see cref="ValueOnlyKnownAfterAnotherDeployment_SkipsJustThatVariable"/>
    /// and the reason the two paths are separate code. A container env var is a value flowing
    /// <em>out</em> to a consumer, so dropping one leaves the rest of the artifact valid. A
    /// connection property feeds a required schema property of the emitted Radius type, so dropping
    /// it silently would publish an artifact that is either rejected by schema validation or
    /// deployed with a value describing nothing. Without this test, a regression that turned the
    /// failure back into a skip would go unnoticed.
    /// </remarks>
    [Fact]
    public void BackingResourceConnectionPropertyOnlyKnownAfterAnotherDeployment_FailsThePublish()
    {
        var unresolvable = new ThrowingDeploymentOutput(
            new InvalidOperationException("The output 'x' does not have a value."));
 
        var ex = Assert.Throws<RadiusBackingResourceProjectionException>(() => GenerateBicep(b =>
        {
            var pg = b.AddResource(new PostgresServerResource("pg",
            [
                new("username", ReferenceExpression.Create($"app")),
                new("password", ReferenceExpression.Create($"{unresolvable}")),
            ])).WithImage("postgres", "17");
 
            b.AddContainer("api", "myapp/api", "latest").WithReference(pg);
        }));
 
        Assert.Contains("ASPIRERADIUS086", ex.Message, StringComparison.Ordinal);
        Assert.Contains("'password' connection property", ex.Message, StringComparison.Ordinal);
        Assert.Contains("pg", ex.Message, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// A value provider whose resolution fails and which claims no deployment-substituted semantics.
    /// </summary>
    private sealed class ThrowingValueProvider(Exception exception) : IValueProvider
    {
        public ValueTask<string?> GetValueAsync(ValueProviderContext context, CancellationToken cancellationToken = default)
            => throw exception;
 
        public ValueTask<string?> GetValueAsync(CancellationToken cancellationToken = default)
            => throw exception;
    }
 
    /// <summary>
    /// Stands in for a resource output that another deployment substitutes — an Azure
    /// <c>BicepOutputReference</c> is the real-world shape: an <see cref="IValueProvider"/> that also
    /// declares a manifest expression and cannot produce a value until its own deployment has run.
    /// </summary>
    private sealed class ThrowingDeploymentOutput(Exception exception) : IValueProvider, IManifestExpressionProvider
    {
        public string ValueExpression => "{other.outputs.x}";
 
        public ValueTask<string?> GetValueAsync(ValueProviderContext context, CancellationToken cancellationToken = default)
            => throw exception;
 
        public ValueTask<string?> GetValueAsync(CancellationToken cancellationToken = default)
            => throw exception;
    }
 
    /// <summary>
    /// A resource whose connection string contains a reference back to itself. Nothing in the model
    /// prevents an author from building one, so the classification walk has to survive it.
    /// </summary>
    private sealed class SelfReferentialConnectionStringResource : Resource, IResourceWithConnectionString
    {
        public SelfReferentialConnectionStringResource(string name, ParameterResource inner)
            : base(name)
        {
            ConnectionStringExpression = ReferenceExpression.Create($"{inner};{new ConnectionStringReference(this, false)}");
        }
 
        public ReferenceExpression ConnectionStringExpression { get; }
    }
 
    /// <summary>
    /// A backing resource whose schema publishes no address — the Dapr types, which are consumed
    /// through the sidecar's component configuration rather than through a host and port Aspire
    /// composes — cannot answer an endpoint reference, so emitting a container-shaped address for
    /// it fails instead.
    /// </summary>
    [Fact]
    public void BackingResourceWithNoAddressOutput_FailsThePublish()
    {
        var ex = Assert.Throws<RadiusBackingResourceProjectionException>(() => GenerateBicep(b =>
        {
            var store = b.AddResource(new DaprStateStoreResource("statestore"))
                .WithImage("daprio/dapr", "1.14")
                .WithEndpoint(scheme: "tcp", name: "tcp", port: 3500, targetPort: 3500);
 
            b.AddContainer("api", "myapp/api", "latest")
                .WithEnvironment("STORE_HOST", store.GetEndpoint("tcp").Property(EndpointProperty.Host));
        }));
 
        Assert.Contains("ASPIRERADIUS079", ex.Message, StringComparison.Ordinal);
        Assert.Contains("statestore", ex.Message, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// Stands in for a Dapr state store, whose Radius type is a backing resource that publishes no
    /// address. The Dapr hosting package is not referenced here; the class derives from
    /// <see cref="ContainerResource"/> and carries an image for the same reason as
    /// <see cref="PostgresServerResource"/>, and its name matches the real type exactly because
    /// <c>ResourceTypeMapper</c> keys its mappings on the simple type name.
    /// </summary>
    private sealed class DaprStateStoreResource(string name) : ContainerResource(name), IResourceWithEndpoints
    {
    }
 
    /// <summary>
    /// Stands in for a resource that maps to the Postgres Radius type but exposes no connection
    /// properties, so neither of the type's required schema properties can be supplied. Every
    /// shipped resource that maps there exposes both, so the guard cannot be reached with one.
    /// <para>
    /// It derives from <see cref="ContainerResource"/> and carries an image because only a resource
    /// the publisher classifies into the environment is emitted at all, and it is named to match the
    /// real type exactly: <c>ResourceTypeMapper</c> keys its mappings on the simple type name.
    /// </para>
    /// </summary>
    private sealed class PostgresServerResource(string name, KeyValuePair<string, ReferenceExpression>[] connectionProperties)
        : ContainerResource(name), IResourceWithConnectionString
    {
        public ReferenceExpression ConnectionStringExpression => ReferenceExpression.Create($"Host=pg");
 
        public IEnumerable<KeyValuePair<string, ReferenceExpression>> GetConnectionProperties() => connectionProperties;
    }
}