// 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;
}
}