File: Publishing\ContainerSecretEnvironmentTests.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.Radius.Publishing.Constructs;
using Aspire.Hosting.Radius.ResourceMapping;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
 
namespace Aspire.Hosting.Radius.Tests.Publishing;
 
/// <summary>
/// Covers how credential-bearing container environment variables are published.
/// </summary>
/// <remarks>
/// Routing a credential to an <c>@secure()</c> Bicep <c>param</c> only keeps it out of the
/// published artifact; the resolved string still lands in the Kubernetes <c>Deployment</c> spec and
/// its rollout history. These tests pin that such values are emitted as
/// <c>valueFrom.secretKeyRef</c> instead, and that values carrying nothing sensitive are left as
/// plain <c>value</c> entries so the artifact stays readable.
/// </remarks>
public class ContainerSecretEnvironmentTests
{
    private static string GenerateBicep(
        Action<IDistributedApplicationBuilder> configure,
        Action<RadiusInfrastructureOptions>? configureInfrastructure = null)
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var radius = builder.AddRadiusEnvironment("myenv");
        if (configureInfrastructure is not null)
        {
            radius.ConfigureRadiusInfrastructure(configureInfrastructure);
        }
 
        configure(builder);
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var radiusEnv = model.Resources.OfType<RadiusEnvironmentResource>().First();
        RadiusTestHelper.AttachDeploymentTargets(radiusEnv, model);
 
        return new RadiusBicepPublishingContext(radiusEnv).GenerateBicep(model, new RecordingLogger());
    }
 
    [Fact]
    public void SecretParameter_IsReferencedFromSecret_NotWrittenIntoTheContainerSpec()
    {
        var secret = default(IResourceBuilder<ParameterResource>);
        var bicep = GenerateBicep(builder =>
        {
            secret = builder.AddParameter("apikey", secret: true);
            builder.AddContainer("api", "myapp/api:latest")
                .WithEnvironment("API_KEY", secret!);
        });
 
        Assert.Contains(
            """
                      API_KEY: {
                        valueFrom: {
                          secretKeyRef: {
                            secretName: 'api-env-secret'
                            key: 'API_KEY'
                          }
                        }
                      }
            """.ReplaceLineEndings(),
            bicep.ReplaceLineEndings());
 
        // The composed value moves to the secret rather than disappearing.
        Assert.Contains(
            """
                  API_KEY: {
                    value: apikey
                    encoding: 'string'
                  }
            """.ReplaceLineEndings(),
            bicep.ReplaceLineEndings());
    }
 
    /// <summary>
    /// A mixed-case Aspire resource name still produces a Kubernetes-legal secret name.
    /// </summary>
    /// <remarks>
    /// The Kubernetes secrets recipe uses the Radius resource name verbatim as the
    /// <c>core/Secret</c> <c>metadata.name</c>, which must be an RFC 1123 subdomain, so
    /// <c>MyApi-env-secret</c> would be rejected at apply time. The container recipe lowercases for
    /// itself, so nothing else in the emitted document catches this.
    /// </remarks>
    [Fact]
    public void UppercaseResourceName_ProducesALowercaseSecretName()
    {
        var bicep = GenerateBicep(builder =>
        {
            var secret = builder.AddParameter("apikey", secret: true);
            builder.AddContainer("MyApi", "myapp/api:latest")
                .WithEnvironment("API_KEY", secret);
        });
 
        Assert.Contains("name: 'myapi-env-secret'", bicep, StringComparison.Ordinal);
        Assert.Contains("secretName: 'myapi-env-secret'", bicep, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// The same normalization applies to the credential secret emitted for a backing resource,
    /// which is named after that resource rather than a container.
    /// </summary>
    [Fact]
    public void UppercaseBackingResourceName_ProducesALowercaseSecretName()
    {
        var bicep = GenerateBicep(builder =>
        {
            // An explicit user name is required for RabbitMQ (ASPIRERADIUS082), so supply one
            // rather than tripping that diagnostic before the secret is ever emitted.
            var user = builder.AddParameter("queueuser", "app");
            builder.AddRabbitMQ("MyQueue", userName: user);
        });
 
        Assert.Contains("name: 'myqueue-password-secret'", bicep, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// A value with nothing sensitive in it stays a plain <c>value</c>. Routing everything through a
    /// secret would make the deployed spec unreadable and bloat the emitted secret for no benefit.
    /// </summary>
    [Fact]
    public void NonSecretValue_StaysAPlainValue()
    {
        var bicep = GenerateBicep(builder =>
        {
            var plain = builder.AddParameter("region", "westus");
            builder.AddContainer("api", "myapp/api:latest")
                .WithEnvironment("REGION", plain)
                .WithEnvironment("MODE", "production");
        });
 
        Assert.Contains("MODE: {", bicep);
        Assert.Contains("value: 'production'", bicep);
        Assert.Contains("value: region", bicep);
        Assert.DoesNotContain("secretKeyRef", bicep);
        Assert.DoesNotContain(RadiusResourceTypes.SecuritySecrets, bicep);
    }
 
    /// <summary>
    /// The per-fragment <c>uriComponent()</c> escaping has to survive the move into the secret.
    /// This is the reason the whole composed expression is written into the secret rather than the
    /// container referencing a secret-backed fragment through kubelet's <c>$(VAR)</c> expansion:
    /// <c>$(VAR)</c> substitutes at pod start, long after Bicep could have escaped the fragment, so
    /// a password containing <c>@</c>, <c>:</c> or <c>/</c> would corrupt the URI.
    /// </summary>
    [Fact]
    public void ComposedUriValue_KeepsPerFragmentEscaping()
    {
        var bicep = GenerateBicep(builder =>
        {
            var password = builder.AddParameter("pw", secret: true);
            var rabbit = builder.AddRabbitMQ("rabbit", userName: builder.AddParameter("rabbituser", "app"), password: password);
            builder.AddContainer("api", "myapp/api:latest").WithReference(rabbit);
        });
 
        Assert.Contains("uriComponent(pw)", bicep);
    }
 
    /// <summary>
    /// <c>Radius.Security/secrets</c> is recipe-backed, so a secret emitted for a container's
    /// environment has to pull its recipe into the pack. The pack is built before container
    /// environments are resolved, so this is the case that regresses if the top-up is dropped: an
    /// app with no backing resource at all still emits a secret.
    /// </summary>
    [Fact]
    public void ContainerSecret_RegistersTheSecretsRecipe_EvenWithNoBackingResources()
    {
        var bicep = GenerateBicep(builder =>
        {
            var password = builder.AddParameter("pw", secret: true);
            builder.AddContainer("api", "myapp/api:latest")
                .WithEnvironment("PW", password);
        });
 
        Assert.Contains($"'{RadiusResourceTypes.SecuritySecrets}': {{", bicep);
        Assert.Contains("kube-recipes/secrets", bicep);
    }
 
    /// <summary>
    /// The secrets recipe must be registered exactly once when a backing resource already pulled it
    /// in for its own credential.
    /// </summary>
    [Fact]
    public void SecretsRecipe_IsRegisteredOnce_WhenABackingResourceAlsoNeedsIt()
    {
        var bicep = GenerateBicep(builder =>
        {
            var password = builder.AddParameter("pw", secret: true);
            var rabbit = builder.AddRabbitMQ("rabbit", userName: builder.AddParameter("rabbituser", "app"), password: password);
            builder.AddContainer("api", "myapp/api:latest").WithReference(rabbit);
        });
 
        var occurrences = bicep.Split($"'{RadiusResourceTypes.SecuritySecrets}': {{").Length - 1;
        Assert.Equal(1, occurrences);
    }
 
    /// <summary>
    /// Kubernetes restricts secret data keys to <c>[-._a-zA-Z0-9]+</c>. An environment variable name
    /// outside that set would be rejected by the API server at deploy time, with no indication of
    /// which variable caused it, so the publisher rejects it while the name is still attributable.
    /// </summary>
    [Fact]
    public void CredentialInAVariableWhoseNameIsNotAValidSecretKey_FailsWithADiagnostic()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(builder =>
        {
            var password = builder.AddParameter("pw", secret: true);
            builder.AddContainer("api", "myapp/api:latest")
                .WithEnvironment("BAD KEY", password);
        }));
 
        Assert.Contains("ASPIRERADIUS083", ex.Message);
        Assert.Contains("BAD KEY", ex.Message);
    }
 
    /// <summary>
    /// A <c>..</c>-prefixed name passes the <c>[-._a-zA-Z0-9]+</c> character class but is still
    /// rejected by the API server, so it has to be rejected here too.
    /// </summary>
    /// <remarks>
    /// apimachinery's <c>IsConfigMapKey</c> delegates to <c>hasChDirPrefix</c>, which rejects
    /// <c>.</c>, <c>..</c> <em>and</em> every name starting with <c>..</c> — the kubelet's
    /// atomic-writer projects each key as a file alongside its own <c>..data</c> symlink, so a
    /// <c>..</c>-prefixed key would collide with that machinery. Pinned because the character
    /// class alone reads as though <c>..token</c> were acceptable. See
    /// https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apimachinery/pkg/util/validation/validation.go.
    /// </remarks>
    [Fact]
    public void CredentialInAVariableWhoseNameStartsWithParentDirectory_FailsWithADiagnostic()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(builder =>
        {
            var password = builder.AddParameter("pw", secret: true);
            builder.AddContainer("api", "myapp/api:latest")
                .WithEnvironment("..token", password);
        }));
 
        Assert.Contains("ASPIRERADIUS083", ex.Message);
        Assert.Contains("..token", ex.Message);
    }
 
    /// <summary>
    /// The container's secret is consumed only by that container, so it can safely read a backing
    /// resource's outputs. The credential secret a backing resource consumes by resource ID must
    /// stay a separate resource, or the graph becomes <c>secret → resource → secret</c>.
    /// </summary>
    [Fact]
    public void ContainerSecret_IsDistinctFromTheBackingResourceCredentialSecret()
    {
        var bicep = GenerateBicep(builder =>
        {
            var password = builder.AddParameter("pw", secret: true);
            var rabbit = builder.AddRabbitMQ("rabbit", userName: builder.AddParameter("rabbituser", "app"), password: password);
            builder.AddContainer("api", "myapp/api:latest").WithReference(rabbit);
        });
 
        Assert.Contains("resource api_env_secret 'Radius.Security/secrets@", bicep);
        Assert.Contains("resource rabbit_password_secret 'Radius.Security/secrets@", bicep);
 
        // The container's secret is never referenced by the backing resource.
        var rabbitResource = bicep[bicep.IndexOf("resource rabbit 'Radius.Messaging/rabbitMQ@", StringComparison.Ordinal)..];
        rabbitResource = rabbitResource[..rabbitResource.IndexOf("\n}", StringComparison.Ordinal)];
        Assert.DoesNotContain("api_env_secret", rabbitResource);
    }
 
    /// <summary>
    /// The secret's scope references have to follow a callback that renames the environment or
    /// application construct, exactly as a backing resource's credential secret does. Without this
    /// the secret emits <c>environment: &lt;old&gt;.id</c>, which is not a symbol that exists.
    /// </summary>
    [Fact]
    public void RenamingTheEnvironmentAndApplication_RepairsTheContainerSecretScope()
    {
        var bicep = GenerateBicep(builder =>
        {
            var password = builder.AddParameter("pw", secret: true);
            builder.AddContainer("api", "myapp/api:latest")
                .WithEnvironment("PW", password);
 
        }, opts =>
        {
            opts.Environments[0].BicepIdentifier = "renamedEnv";
            opts.Applications[0].BicepIdentifier = "renamedApp";
        });
 
        Assert.Contains("environment: renamedEnv.id", bicep);
        Assert.Contains("application: renamedApp.id", bicep);
        Assert.DoesNotContain("environment: myenv.id", bicep);
    }
 
    /// <summary>
    /// A value composed purely from a secret parameter has no backing-resource projection, so it is
    /// not tracked by the projection repair path. Its reference to the secret is just as breakable,
    /// so removing the secret has to be rejected rather than emitted as a dangling reference — the
    /// API server accepts the artifact and the pod then fails to start.
    /// </summary>
    [Fact]
    public void RemovingTheContainerSecret_FailsEvenWithNoBackingResourceProjection()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(builder =>
        {
            var password = builder.AddParameter("pw", secret: true);
            builder.AddContainer("api", "myapp/api:latest")
                .WithEnvironment("PW", password);
        }, opts => opts.SecuritySecrets.Clear()));
 
        Assert.Contains("ASPIRERADIUS084", ex.Message);
        Assert.Contains("PW", ex.Message);
    }
 
    /// <summary>
    /// Removing just the key leaves the secret resource in place, so the resource-level check does
    /// not catch it; the pod still fails to start on a missing key.
    /// </summary>
    [Fact]
    public void RemovingTheSecretDataKey_FailsWithADiagnostic()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(builder =>
        {
            var password = builder.AddParameter("pw", secret: true);
            builder.AddContainer("api", "myapp/api:latest")
                .WithEnvironment("PW", password);
        }, opts => opts.SecuritySecrets[0].Data.Clear()));
 
        Assert.Contains("ASPIRERADIUS084", ex.Message);
        Assert.Contains("PW", ex.Message);
    }
 
    /// <summary>
    /// The env var references the secret by resource <em>name</em>, so renaming the resource has to
    /// re-sync the reference.
    /// </summary>
    [Fact]
    public void RenamingTheSecretResource_ResyncsTheReference()
    {
        var bicep = GenerateBicep(builder =>
        {
            var password = builder.AddParameter("pw", secret: true);
            builder.AddContainer("api", "myapp/api:latest")
                .WithEnvironment("PW", password);
        }, opts => opts.SecuritySecrets[0].SecretName = "custom-secret");
 
        Assert.Contains("secretName: 'custom-secret'", bicep);
        Assert.DoesNotContain("secretName: 'api-env-secret'", bicep);
    }
 
    /// <summary>
    /// A callback that re-points the reference itself owns the result — last-write-wins, as
    /// everywhere else on the escape-hatch surface.
    /// </summary>
    [Fact]
    public void CallbackThatRepointsTheReference_IsPreserved()
    {
        var bicep = GenerateBicep(builder =>
        {
            var password = builder.AddParameter("pw", secret: true);
            builder.AddContainer("api", "myapp/api:latest")
                .WithEnvironment("PW", password);
        }, opts =>
        {
            opts.Containers[0].Env["PW"].Value!.SecretName = "my-own-secret";
            opts.SecuritySecrets[0].SecretName = "renamed";
        });
 
        Assert.Contains("secretName: 'my-own-secret'", bicep);
    }
 
    /// <summary>
    /// A callback can re-point the key alone, leaving <c>secretName</c> aimed at the generated
    /// secret. The reference then still resolves to a resource the publisher owns, so the key it
    /// now names is checked against that resource: a key that is not there publishes an artifact
    /// Radius accepts and surfaces only as a pod that never starts.
    /// </summary>
    [Fact]
    public void CallbackThatRepointsTheSecretKeyToAMissingKey_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(builder =>
        {
            var password = builder.AddParameter("pw", secret: true);
            builder.AddContainer("api", "myapp/api:latest")
                .WithEnvironment("PW", password);
        }, opts => opts.Containers[0].Env["PW"].Value!.SecretKey = "not-a-key"));
 
        Assert.Contains("ASPIRERADIUS084", ex.Message);
        Assert.Contains("'PW'", ex.Message);
        Assert.Contains("not-a-key", ex.Message);
    }
 
    /// <summary>
    /// The counterpart to the check above: re-pointing the key at an entry the callback also added
    /// is a legitimate use of the escape hatch, so it has to keep publishing.
    /// </summary>
    [Fact]
    public void CallbackThatRepointsTheSecretKeyToAnExistingKey_IsPreserved()
    {
        var bicep = GenerateBicep(builder =>
        {
            var password = builder.AddParameter("pw", secret: true);
            builder.AddContainer("api", "myapp/api:latest")
                .WithEnvironment("PW", password);
        }, opts =>
        {
            opts.SecuritySecrets[0].Data["extra-key"] = new RadiusSecuritySecretDataEntryConstruct
            {
                Encoding = "string",
                Value = "extra-value",
            };
            opts.Containers[0].Env["PW"].Value!.SecretKey = "extra-key";
        });
 
        Assert.Contains("key: 'extra-key'", bicep);
    }
 
    /// <summary>
    /// The <c>value</c> and <c>valueFrom.secretKeyRef</c> forms are mutually exclusive, and
    /// Kubernetes rejects an environment variable that sets both. All three properties are public,
    /// so only a post-callback check can enforce it.
    /// </summary>
    [Fact]
    public void CallbackThatSetsBothValueAndSecretReference_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(builder =>
        {
            var password = builder.AddParameter("pw", secret: true);
            builder.AddContainer("api", "myapp/api:latest")
                .WithEnvironment("PW", password);
        }, opts => opts.Containers[0].Env["PW"].Value!.Value = "literal"));
 
        Assert.Contains("ASPIRERADIUS087", ex.Message);
        Assert.Contains("'PW'", ex.Message);
    }
 
    /// <summary>
    /// A <c>secretKeyRef</c> needs both halves: <c>secretName</c> alone names no data entry. Added
    /// as a fresh entry, which is how a callback introduces an environment variable of its own.
    /// </summary>
    [Fact]
    public void CallbackThatAddsAnEntryWithOnlyASecretName_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(builder =>
        {
            builder.AddContainer("api", "myapp/api:latest")
                .WithEnvironment("PLAIN", "value");
        }, opts => opts.Containers[0].Env["ADDED"] = new ContainerEnvVarConstruct { SecretName = "some-secret" }));
 
        Assert.Contains("ASPIRERADIUS087", ex.Message);
        Assert.Contains("SecretName is set but SecretKey is not", ex.Message);
    }
 
    /// <summary>
    /// Setting a secret reference on a variable the publisher already emitted as a plain
    /// <c>value</c> leaves both forms on the entry, which Kubernetes rejects.
    /// </summary>
    [Fact]
    public void CallbackThatAddsASecretReferenceToAPlainValue_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(builder =>
        {
            builder.AddContainer("api", "myapp/api:latest")
                .WithEnvironment("PLAIN", "value");
        }, opts => opts.Containers[0].Env["PLAIN"].Value!.SecretName = "some-secret"));
 
        Assert.Contains("ASPIRERADIUS087", ex.Message);
        Assert.Contains("both a 'value' and a 'valueFrom.secretKeyRef'", ex.Message);
    }
 
    /// <summary>
    /// The mirror of <see cref="CallbackThatAddsAnEntryWithOnlyASecretName_FailsThePublish"/>: a key
    /// with no secret to read it from. Added as a fresh entry, which is how a callback introduces an
    /// environment variable of its own.
    /// </summary>
    [Fact]
    public void CallbackThatAddsAnEntryWithOnlyASecretKey_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(builder =>
        {
            builder.AddContainer("api", "myapp/api:latest")
                .WithEnvironment("PLAIN", "value");
        }, opts => opts.Containers[0].Env["ADDED"] = new ContainerEnvVarConstruct { SecretKey = "some-key" }));
 
        Assert.Contains("ASPIRERADIUS087", ex.Message);
        Assert.Contains("SecretKey is set but SecretName is not", ex.Message);
    }
 
    /// <summary>
    /// A complete reference can still be unrepresentable. Both halves are copied verbatim into the
    /// pod's <c>secretKeyRef</c>, so an invalid literal passes publish and Radius deploy and is
    /// rejected only when the API server creates the pod.
    /// </summary>
    [Fact]
    public void CallbackThatAddsAnEntryWithAnInvalidLiteralSecretName_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(builder =>
        {
            builder.AddContainer("api", "myapp/api:latest")
                .WithEnvironment("PLAIN", "value");
        }, opts => opts.Containers[0].Env["ADDED"] = new ContainerEnvVarConstruct
        {
            SecretName = "Bad_Name",
            SecretKey = "some-key",
        }));
 
        Assert.Contains("ASPIRERADIUS087", ex.Message);
        Assert.Contains("Bad_Name", ex.Message);
        Assert.Contains("DNS-1123 subdomain", ex.Message);
    }
 
    /// <summary>
    /// The key half has its own, different alphabet: a Kubernetes <c>Secret</c> data key permits
    /// letters, digits, <c>-</c>, <c>_</c> and <c>.</c>, so a path-like key is rejected.
    /// </summary>
    [Fact]
    public void CallbackThatAddsAnEntryWithAnInvalidLiteralSecretKey_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(builder =>
        {
            builder.AddContainer("api", "myapp/api:latest")
                .WithEnvironment("PLAIN", "value");
        }, opts => opts.Containers[0].Env["ADDED"] = new ContainerEnvVarConstruct
        {
            SecretName = "some-secret",
            SecretKey = "bad/key",
        }));
 
        Assert.Contains("ASPIRERADIUS087", ex.Message);
        Assert.Contains("bad/key", ex.Message);
    }
}