File: Publishing\PostCallbackSecretValidationTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Radius.Tests\Aspire.Hosting.Radius.Tests.csproj (Aspire.Hosting.Radius.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIRERADIUS006 // Experimental: the secret-store APIs are exercised directly.
 
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 Azure.Provisioning;
using Microsoft.Extensions.DependencyInjection;
 
namespace Aspire.Hosting.Radius.Tests.Publishing;
 
/// <summary>
/// Covers the passes that run <em>after</em> <c>ConfigureRadiusInfrastructure</c> callbacks, where a
/// callback has already had the last word and the publisher can only inspect the final state.
/// </summary>
public class PostCallbackSecretValidationTests : IDisposable
{
    private readonly string _manifestDirectory = Directory.CreateTempSubdirectory("radius-sealed-collision").FullName;
 
    public void Dispose() => Directory.Delete(_manifestDirectory, recursive: true);
 
    private string WriteSealedManifest(string name, string ns) => WriteSealedManifest(name, ns, name, ["username"]);
 
    private string WriteSealedManifest(string name, string ns, string fileName, string[] encryptedKeys)
    {
        var path = Path.Combine(_manifestDirectory, $"{fileName}.sealed.yaml");
        File.WriteAllText(path,
            "apiVersion: bitnami.com/v1alpha1\n" +
            "kind: SealedSecret\n" +
            "metadata:\n" +
            $"  name: {name}\n" +
            $"  namespace: {ns}\n" +
            "spec:\n" +
            "  encryptedData:\n" +
            // Fixed, valid standard-base64 ciphertext: the manifest reader rejects a non-base64
            // value with ASPIRERADIUS044 before any collision check runs.
            string.Concat(encryptedKeys.Select(key => $"    {key}: AgByCIPHERTEXTONLYxx\n")));
        return path;
    }
 
    private static string GenerateBicep(
        Action<IDistributedApplicationBuilder> configure,
        Action<RadiusInfrastructureOptions>? configureInfrastructure = null,
        Action<IResourceBuilder<RadiusEnvironmentResource>>? configureEnvironment = null)
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var radius = builder.AddRadiusEnvironment("myenv");
        configureEnvironment?.Invoke(radius);
        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());
    }
 
    private static void AddContainerWithSecretEnvironment(IDistributedApplicationBuilder builder)
    {
        var password = builder.AddParameter("pw", secret: true);
        builder.AddContainer("api", "myapp/api:latest")
            .WithEnvironment("PW", password);
    }
 
    // ASPIRERADIUS088 — the final Kubernetes object name and data keys.
 
    /// <summary>
    /// The secrets recipe copies <c>SecretName</c> straight into <c>metadata.name</c>, and Radius
    /// does not validate it, so an invalid name compiles as Bicep and is only rejected by the API
    /// server at deploy.
    /// </summary>
    [Fact]
    public void CallbackSettingAnInvalidSecretName_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(
            AddContainerWithSecretEnvironment,
            opts => opts.SecuritySecrets[0].SecretName = "Not_A_Valid_Name"));
 
        Assert.Contains("ASPIRERADIUS088", ex.Message);
        Assert.Contains("Not_A_Valid_Name", ex.Message);
    }
 
    /// <summary>
    /// A name that is only knowable at deploy time cannot be checked, and rejecting it would
    /// contradict last-write-wins — the reference stays coherent because the consuming variable is
    /// re-synced to the same expression.
    /// </summary>
    [Fact]
    public void CallbackSettingASecretNameExpression_StillPublishes()
    {
        var bicep = GenerateBicep(
            AddContainerWithSecretEnvironment,
            opts =>
            {
                var parameter = new ProvisioningParameter("secretName", typeof(string));
                opts.Parameters.Add(parameter);
                opts.SecuritySecrets[0].SecretName = parameter;
            });
 
        Assert.Contains("name: secretName", bicep);
    }
 
    /// <summary>
    /// An unset name renders as null, which the DNS-1123 literal check skips entirely — without a
    /// dedicated gate the resource publishes and emits a <c>Radius.Security/secrets</c> block with
    /// no <c>name</c>, failing only once the API server sees it.
    /// </summary>
    [Fact]
    public void CallbackAddingASecuritySecretWithoutAName_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(
            AddContainerWithSecretEnvironment,
            opts =>
            {
                var orphan = new RadiusSecuritySecretConstruct("orphanSecret")
                {
                    EnvironmentId = opts.SecuritySecrets[0].EnvironmentId,
                };
                orphan.Data["username"] = new RadiusSecuritySecretDataEntryConstruct
                {
                    Encoding = "string",
                    Value = "value",
                };
                opts.SecuritySecrets.Add(orphan);
            }));
 
        Assert.Contains("ASPIRERADIUS088", ex.Message);
        Assert.Contains("orphanSecret", ex.Message);
    }
 
    /// <summary>
    /// A literal <c>Kind</c> carries a data-shape contract Radius does not enforce directly: the
    /// pinned recipe turns the missing-fields error into the Secret's <c>metadata.name</c>, so
    /// publish would otherwise succeed and fail during deployment with an unrelated-looking name
    /// error.
    /// </summary>
    [Fact]
    public void CallbackSettingAKindWithoutItsRequiredKeys_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(
            AddContainerWithSecretEnvironment,
            opts => opts.SecuritySecrets[0].Kind = "basicAuthentication"));
 
        Assert.Contains("ASPIRERADIUS092", ex.Message);
        Assert.Contains("basicAuthentication", ex.Message);
        Assert.Contains("username", ex.Message);
    }
 
    /// <summary>
    /// <c>kind</c> is a closed union of string literals in the types.json the pinned extension
    /// resolves to, so an unrecognized literal fails <c>bicep build</c> during deployment. A newer
    /// control plane cannot make it valid, because the compile step reads the pinned definitions —
    /// publishing it would only produce an artifact that cannot deploy.
    /// </summary>
    [Fact]
    public void CallbackSettingAnUnrecognizedKind_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(
            AddContainerWithSecretEnvironment,
            opts => opts.SecuritySecrets[0].Kind = "somethingNewer"));
 
        Assert.Contains("ASPIRERADIUS092", ex.Message);
        Assert.Contains("somethingNewer", ex.Message);
        Assert.Contains("certificate-pkcs12", ex.Message);
    }
 
    /// <summary>
    /// The legacy <c>Applications.Core/secretStores</c> spelling of the certificate case is not a
    /// member of this type's enum, so carrying it across during migration is a deploy-time schema
    /// rejection rather than a kind a newer control plane might understand.
    /// </summary>
    [Fact]
    public void CallbackSettingTheLegacyCertificateKind_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(
            AddContainerWithSecretEnvironment,
            opts => opts.SecuritySecrets[0].Kind = "certificate"));
 
        Assert.Contains("ASPIRERADIUS092", ex.Message);
        Assert.Contains("certificate-pem", ex.Message);
    }
 
    /// <summary>
    /// The replacement spelling carries the same key contract the legacy <c>certificate</c> type
    /// did, and must be recognized — sharing the legacy table would have missed it entirely.
    /// </summary>
    [Fact]
    public void CallbackSettingCertificatePemWithoutItsRequiredKeys_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(
            AddContainerWithSecretEnvironment,
            opts => opts.SecuritySecrets[0].Kind = "certificate-pem"));
 
        Assert.Contains("ASPIRERADIUS092", ex.Message);
        Assert.Contains("tls.crt", ex.Message);
    }
 
    /// <summary>
    /// The reference recipe documents no per-key contract for <c>certificate-pkcs12</c>, so it is
    /// accepted with whatever data the callback supplied.
    /// </summary>
    [Fact]
    public void CallbackSettingCertificatePkcs12_StillPublishes()
    {
        var bicep = GenerateBicep(
            AddContainerWithSecretEnvironment,
            opts => opts.SecuritySecrets[0].Kind = "certificate-pkcs12");
 
        Assert.Contains("certificate-pkcs12", bicep, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// <c>properties.environment</c> is required by the type. An unset <see cref="BicepValue{T}"/>
    /// is simply omitted from the emitted Bicep, so the artifact would compile and be rejected only
    /// by Radius schema validation at deploy.
    /// </summary>
    [Fact]
    public void CallbackAddingASecuritySecretWithoutAnEnvironment_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(
            AddContainerWithSecretEnvironment,
            opts =>
            {
                var orphan = new RadiusSecuritySecretConstruct("orphanSecret")
                {
                    SecretName = "orphan-secret",
                };
                orphan.Data["username"] = new RadiusSecuritySecretDataEntryConstruct { Value = "value" };
                opts.SecuritySecrets.Add(orphan);
            }));
 
        Assert.Contains("ASPIRERADIUS093", ex.Message);
        Assert.Contains(nameof(RadiusSecuritySecretConstruct.EnvironmentId), ex.Message);
    }
 
    /// <summary>
    /// Every <c>data</c> entry must carry a value; an entry without one emits an empty object that
    /// Radius rejects.
    /// </summary>
    [Fact]
    public void CallbackAddingADataEntryWithoutAValue_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(
            AddContainerWithSecretEnvironment,
            opts => opts.SecuritySecrets[0].Data["extra"] = new RadiusSecuritySecretDataEntryConstruct
            {
                Encoding = "string",
            }));
 
        Assert.Contains("ASPIRERADIUS093", ex.Message);
        Assert.Contains("extra", ex.Message);
    }
 
    /// <summary>
    /// The encoding vocabulary is the one place this type diverges from the legacy type it
    /// replaces — <c>string</c>/<c>base64</c> rather than <c>raw</c>/<c>base64</c> — so the legacy
    /// spelling is the likely mistake and the message names the divergence.
    /// </summary>
    [Fact]
    public void CallbackSettingTheLegacyRawEncoding_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(
            AddContainerWithSecretEnvironment,
            opts => opts.SecuritySecrets[0].Data.Values.First().Value!.Encoding = "raw"));
 
        Assert.Contains("ASPIRERADIUS093", ex.Message);
        Assert.Contains("'raw'", ex.Message);
    }
 
    /// <summary>
    /// The mirror of <see cref="CallbackAddingADataEntryWithoutAValue_FailsThePublish"/> reached
    /// through the dictionary rather than the construct: a key whose entry is null carries no value
    /// either, and there is no construct left to inspect.
    /// </summary>
    [Fact]
    public void CallbackAddingANullDataEntry_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(
            AddContainerWithSecretEnvironment,
            opts => opts.SecuritySecrets[0].Data["extra"] = null!));
 
        Assert.Contains("ASPIRERADIUS093", ex.Message);
        Assert.Contains("extra", ex.Message);
    }
 
    /// <summary>
    /// The legacy <c>Applications.Core/secretStores</c> counterpart of
    /// <see cref="CallbackSettingAKindWithoutItsRequiredKeys_FailsThePublish"/>. The two
    /// vocabularies are validated from separate tables, so the legacy <c>certificate</c> spelling
    /// must still be recognized here even though the replacement type rejects it.
    /// </summary>
    [Fact]
    public void CallbackSettingALegacyStoreTypeWithoutItsRequiredKeys_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(
            builder =>
            {
                builder.AddRadiusSecretStore("store", RadiusSecretStoreType.Generic)
                    .WithData("tls.crt", builder.AddParameter("cert", secret: true));
                builder.AddContainer("api", "myapp/api:latest");
            },
            opts => opts.SecretStores[0].StoreType = "certificate"));
 
        Assert.Contains("ASPIRERADIUS092", ex.Message);
        Assert.Contains("tls.key", ex.Message);
    }
 
    /// <summary>
    /// The legacy store type's <c>properties.type</c> is a closed union in the pinned extension
    /// types too, so an unrecognized literal is rejected rather than emitted as an artifact that
    /// cannot compile.
    /// </summary>
    [Fact]
    public void CallbackSettingAnUnrecognizedLegacyStoreType_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(
            builder =>
            {
                builder.AddRadiusSecretStore("store", RadiusSecretStoreType.Generic)
                    .WithData("key", builder.AddParameter("val", secret: true));
                builder.AddContainer("api", "myapp/api:latest");
            },
            opts => opts.SecretStores[0].StoreType = "somethingNewer"));
 
        Assert.Contains("ASPIRERADIUS092", ex.Message);
        Assert.Contains("somethingNewer", ex.Message);
        Assert.Contains("azureWorkloadIdentity", ex.Message);
    }
 
    /// <summary>
    /// The mirror of <see cref="CallbackSettingAnUnrecognizedKind_FailsThePublish"/> for the
    /// encoding vocabulary, which the pinned types.json declares as the closed set
    /// <c>string</c>/<c>base64</c>.
    /// </summary>
    [Fact]
    public void CallbackSettingAnUnrecognizedEncoding_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(
            AddContainerWithSecretEnvironment,
            opts => opts.SecuritySecrets[0].Data.Values.First().Value!.Encoding = "somethingNewer"));
 
        Assert.Contains("ASPIRERADIUS093", ex.Message);
        Assert.Contains("somethingNewer", ex.Message);
    }
 
    /// <summary>
    /// Data keys are copied verbatim into the Kubernetes <c>Secret</c>'s <c>data</c> map, which
    /// permits a much narrower alphabet than Bicep does.
    /// </summary>
    [Fact]
    public void CallbackAddingAnInvalidDataKeyToASecuritySecret_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(
            AddContainerWithSecretEnvironment,
            opts => opts.SecuritySecrets[0].Data["not a key"] = new RadiusSecuritySecretDataEntryConstruct
            {
                Encoding = "string",
                Value = "value",
            }));
 
        Assert.Contains("ASPIRERADIUS088", ex.Message);
        Assert.Contains("not a key", ex.Message);
    }
 
    /// <summary>
    /// Secret stores populate <c>Data</c> identically in the inline and existing-secret modes — one
    /// carries a value, the other names a key to expose — so the key alphabet applies to both.
    /// </summary>
    [Fact]
    public void CallbackAddingAnInvalidDataKeyToASecretStore_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(
            builder =>
            {
                builder.AddRadiusSecretStore("store", RadiusSecretStoreType.Generic)
                    .WithData("good", builder.AddParameter("pw", secret: true));
                builder.AddContainer("api", "myapp/api:latest");
            },
            opts => opts.SecretStores[0].Data["not a key"] = new RadiusSecretStoreDataEntryConstruct()));
 
        Assert.Contains("ASPIRERADIUS088", ex.Message);
        Assert.Contains("not a key", ex.Message);
    }
 
    /// <summary>
    /// An inline store's <c>StoreName</c> becomes the backing Kubernetes <c>Secret</c>'s
    /// <c>metadata.name</c> verbatim — the collision pass claims the object under exactly that name
    /// — so it needs the same post-callback check the generated secret's name gets.
    /// </summary>
    [Fact]
    public void CallbackSettingAnInvalidInlineStoreName_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(
            builder =>
            {
                builder.AddRadiusSecretStore("store", RadiusSecretStoreType.Generic)
                    .WithData("good", builder.AddParameter("pw", secret: true));
                builder.AddContainer("api", "myapp/api:latest");
            },
            opts => opts.SecretStores[0].StoreName = "Not_A_Valid_Name"));
 
        Assert.Contains("ASPIRERADIUS088", ex.Message);
        Assert.Contains("Not_A_Valid_Name", ex.Message);
    }
 
    /// <summary>
    /// An existing-secret store names its Kubernetes object through <c>resource</c>, leaving
    /// <c>StoreName</c> as the Radius-side resource name only. Validating it as an object name there
    /// would reject a store that never materializes one.
    /// </summary>
    [Fact]
    public void CallbackSettingAnInvalidExistingStoreName_StillPublishes()
    {
        var bicep = GenerateBicep(
            builder =>
            {
                builder.AddRadiusSecretStore("store", RadiusSecretStoreType.Generic)
                    .WithExistingSecret("default/preexisting", "username");
                builder.AddContainer("api", "myapp/api:latest");
            },
            opts => opts.SecretStores[0].StoreName = "Not_A_Valid_Name");
 
        Assert.Contains("Not_A_Valid_Name", bicep);
    }
 
    /// <summary>
    /// A store name only knowable at deploy time cannot be checked statically, and rejecting it
    /// would contradict the callback surface's last-write-wins contract.
    /// </summary>
    [Fact]
    public void CallbackSettingAnInlineStoreNameExpression_StillPublishes()
    {
        var bicep = GenerateBicep(
            builder =>
            {
                builder.AddRadiusSecretStore("store", RadiusSecretStoreType.Generic)
                    .WithData("good", builder.AddParameter("pw", secret: true));
                builder.AddContainer("api", "myapp/api:latest");
            },
            opts =>
            {
                var parameter = new ProvisioningParameter("storeName", typeof(string));
                opts.Parameters.Add(parameter);
                opts.SecretStores[0].StoreName = parameter;
            });
 
        Assert.Contains("name: storeName", bicep);
    }
 
    // ASPIRERADIUS090 — two Radius resources, one Kubernetes object.
 
    /// <summary>
    /// Radius scopes uniqueness by resource type, so a generated container env secret and a
    /// user-declared secret store can be distinct Radius resources that deploy as the same cluster
    /// object. The second one applied overwrites the first, and the consumer of the overwritten
    /// object reads a key that no longer exists.
    /// </summary>
    [Fact]
    public void SecretStoreNamedLikeTheGeneratedContainerSecret_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(builder =>
        {
            var password = builder.AddParameter("pw", secret: true);
            builder.AddRadiusSecretStore("api-env-secret", RadiusSecretStoreType.Generic)
                .WithData("other", password);
            // `Api` sanitizes to the Bicep identifier `Api_env_secret`, which is distinct from the
            // store's `api_env_secret`, so the existing identifier check (ASPIRERADIUS056) passes —
            // yet both name the same lowercase Kubernetes object.
            builder.AddContainer("Api", "myapp/api:latest")
                .WithEnvironment("PW", password);
        }));
 
        Assert.Contains("ASPIRERADIUS090", ex.Message);
        Assert.Contains("api-env-secret", ex.Message);
    }
 
    /// <summary>
    /// Renaming one side resolves the collision, so the check must be on the final physical name and
    /// not on the identifiers the publisher started from.
    /// </summary>
    [Fact]
    public void CallbackRenamingOneOfTheCollidingSecrets_ResolvesTheCollision()
    {
        var bicep = GenerateBicep(
            builder =>
            {
                var password = builder.AddParameter("pw", secret: true);
                builder.AddRadiusSecretStore("api-env-secret", RadiusSecretStoreType.Generic)
                    .WithData("other", password);
                // `Api` sanitizes to the Bicep identifier `Api_env_secret`, which is distinct from
                // the store's `api_env_secret`, so the existing identifier check (ASPIRERADIUS056)
                // passes — yet both name the same lowercase Kubernetes object.
                builder.AddContainer("Api", "myapp/api:latest")
                    .WithEnvironment("PW", password);
            },
            opts => opts.SecretStores.Single(s => s.StoreName.Value?.ToString() == "api-env-secret").StoreName = "renamed-store");
 
        Assert.Contains("name: 'renamed-store'", bicep);
        Assert.Contains("name: 'api-env-secret'", bicep);
    }
 
    /// <summary>
    /// Two secrets sharing a name is only a collision when they also share a namespace, and the
    /// namespace has to be read from the final environment construct because a callback can change
    /// it.
    /// </summary>
    [Fact]
    public void SecretsWithTheSameNameInDifferentNamespaces_StillPublish()
    {
        var bicep = GenerateBicep(
            builder =>
            {
                var password = builder.AddParameter("pw", secret: true);
                builder.AddRadiusSecretStore("api-env-secret", RadiusSecretStoreType.Generic)
                    .WithData("other", password);
                // `Api` sanitizes to the Bicep identifier `Api_env_secret`, which is distinct from
                // the store's `api_env_secret`, so the existing identifier check (ASPIRERADIUS056)
                // passes — yet both name the same lowercase Kubernetes object.
                builder.AddContainer("Api", "myapp/api:latest")
                    .WithEnvironment("PW", password);
            },
            opts => opts.LegacyEnvironments[0].ComputeNamespace = "other-namespace");
 
        Assert.Contains("namespace: 'other-namespace'", bicep);
    }
 
    // ASPIRERADIUS087 — an environment variable that sets none of its three forms.
 
    /// <summary>
    /// All three properties are public and independently clearable, so a callback can leave a
    /// variable that emits as an empty object. Kubernetes rejects it, but only at deploy.
    /// </summary>
    [Fact]
    public void CallbackClearingEveryFormOfAnEnvironmentVariable_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(
            builder => builder.AddContainer("api", "myapp/api:latest")
                .WithEnvironment("PLAIN", "value"),
            opts => opts.Containers[0].Env["PLAIN"] = new ContainerEnvVarConstruct()));
 
        Assert.Contains("ASPIRERADIUS087", ex.Message);
        Assert.Contains("PLAIN", ex.Message);
    }
    private static void AddRabbitMqWithGeneratedPassword(IDistributedApplicationBuilder builder)
    {
        // An explicit user name is required: the default `guest` is loopback-only on a real broker,
        // and the publisher rejects it (ASPIRERADIUS082) before any of the passes under test run.
        var rabbit = builder.AddRabbitMQ("rabbit", userName: builder.AddParameter("rabbituser"));
        builder.AddContainer("api", "myapp/api:latest")
            .WithReference(rabbit);
    }
 
    // ASPIRERADIUS089 — the credential a UDT resource consumes by resource ID.
 
    /// <summary>
    /// Unlike a container env secret — whose only reader is the variable pointing at it — this value
    /// is handed to the recipe that provisions the broker, while the matching credential was already
    /// composed into every consumer's connection string. Removing the key leaves the recipe unable
    /// to provision at all.
    /// </summary>
    [Fact]
    public void CallbackRemovingTheCredentialKey_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(
            AddRabbitMqWithGeneratedPassword,
            opts => opts.SecuritySecrets[0].Data.Clear()));
 
        Assert.Contains("ASPIRERADIUS089", ex.Message);
    }
 
    /// <summary>
    /// A swapped credential is worse than a missing one: the deploy succeeds and the broker comes up
    /// with a password no consumer was told about, so it surfaces only as an authentication failure
    /// at runtime.
    /// </summary>
    [Fact]
    public void CallbackReplacingTheCredentialEntry_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(
            AddRabbitMqWithGeneratedPassword,
            opts =>
            {
                var secret = opts.SecuritySecrets[0];
                var key = secret.Data.Keys.First();
                secret.Data[key] = new RadiusSecuritySecretDataEntryConstruct
                {
                    Encoding = "string",
                    Value = "hunter2",
                };
            }));
 
        Assert.Contains("ASPIRERADIUS089", ex.Message);
    }
 
    /// <summary>
    /// Mutating the existing entry in place desynchronizes broker and clients exactly as replacing
    /// it does, and leaves the entry object identical, so identity alone cannot catch it.
    /// </summary>
    [Fact]
    public void CallbackMutatingTheCredentialValueInPlace_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(
            AddRabbitMqWithGeneratedPassword,
            opts => opts.SecuritySecrets[0].Data.Values.First().Value!.Value = "hunter2"));
 
        Assert.Contains("ASPIRERADIUS089", ex.Message);
    }
 
    /// <summary>
    /// The encoding decides how the recipe interprets the value: <c>base64</c> makes it decode
    /// before writing the Kubernetes <c>Secret</c>, so the broker is provisioned with a different
    /// credential than the byte-identical value consumers were given. Comparing the value alone
    /// would let this through and leave a runtime-only authentication failure.
    /// </summary>
    [Fact]
    public void CallbackMutatingTheCredentialEncodingInPlace_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(
            AddRabbitMqWithGeneratedPassword,
            opts => opts.SecuritySecrets[0].Data.Values.First().Value!.Encoding = "base64"));
 
        Assert.Contains("ASPIRERADIUS089", ex.Message);
        Assert.Contains("encoding", ex.Message);
    }
 
    /// <summary>
    /// Blanking the encoding lets the type's own default decide, which is no more knowable to the
    /// consumers that already hold the credential than an explicit change is.
    /// </summary>
    [Fact]
    public void CallbackBlankingTheCredentialEncoding_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(
            AddRabbitMqWithGeneratedPassword,
            opts => opts.SecuritySecrets[0].Data.Values.First().Value!.Encoding = ""));
 
        Assert.Contains("ASPIRERADIUS089", ex.Message);
    }
 
    /// <summary>
    /// An expression-valued encoding is not knowable while publishing, so it cannot be shown to
    /// still be <c>string</c>. Unlike a secret's <em>name</em> — where an expression stays coherent
    /// because both sides resolve to the same value — an unknowable encoding decides how a credential
    /// consumers already hold gets decoded, so it has to be rejected rather than trusted.
    /// </summary>
    [Fact]
    public void CallbackSettingACredentialEncodingExpression_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(
            AddRabbitMqWithGeneratedPassword,
            opts =>
            {
                var parameter = new ProvisioningParameter("encoding", typeof(string));
                opts.Parameters.Add(parameter);
                opts.SecuritySecrets[0].Data.Values.First().Value!.Encoding = parameter;
            }));
 
        Assert.Contains("ASPIRERADIUS089", ex.Message);
    }
 
    /// <summary>
    /// The encoding check must not fire on a callback that leaves the credential alone, which is the
    /// overwhelmingly common case — a callback that only re-scopes or renames the secret.
    /// </summary>
    [Fact]
    public void CallbackLeavingTheCredentialAlone_StillPublishes()
    {
        var bicep = GenerateBicep(
            AddRabbitMqWithGeneratedPassword,
            opts => opts.SecuritySecrets[0].Data.Values.First().Value!.Encoding = "string");
 
        Assert.Contains("Radius.Security/secrets", bicep);
    }
 
    /// <summary>
    /// Assigning the consumer's credential property hands that relationship to the callback, so the
    /// publisher no longer owns what the generated secret holds and a changed value is legitimate.
    /// The ownership check therefore has to run <em>before</em> the ASPIRERADIUS089 checks, which
    /// would otherwise reject a valid last-write-wins configuration.
    /// </summary>
    [Fact]
    public void CallbackTakingOverTheCredentialProperty_MayThenChangeTheValue()
    {
        var bicep = GenerateBicep(
            AddRabbitMqWithGeneratedPassword,
            opts =>
            {
                foreach (var instance in opts.ResourceTypeInstances)
                {
                    if (instance.GetSchemaProperty("password") is not null)
                    {
                        instance.SetSchemaProperty("password", new BicepValue<object>("callback-owned"));
                    }
                }
 
                opts.SecuritySecrets[0].Data.Values.First().Value!.Value = "hunter2";
            });
 
        Assert.Contains("password: 'callback-owned'", bicep);
        Assert.Contains("hunter2", bicep);
    }
 
    // The secrets recipe registration.
 
    /// <summary>
    /// <c>Radius.Security/secrets</c> is recipe-backed, so a secret with no registered recipe fails
    /// the deploy. A callback can remove the entry the first pass registered while leaving the
    /// secret that needs it, and that state has no valid deployment, so it is repaired.
    /// </summary>
    [Fact]
    public void CallbackRemovingTheSecretsRecipeEntry_IsRepairedWithItsRecipeParameters()
    {
        var bicep = GenerateBicep(
            AddContainerWithSecretEnvironment,
            opts => opts.RecipePacks[0].Recipes.Remove(RadiusResourceTypes.SecuritySecrets),
            radius => radius
                .WithRecipeParameters(p => p["envWide"] = "from-environment")
                .WithRecipeParameters(RadiusResourceTypes.SecuritySecrets, p => p["typeScoped"] = "from-type"));
 
        Assert.Contains($"'{RadiusResourceTypes.SecuritySecrets}': {{", bicep);
 
        // The repair has to go through the same construction path as the first pass. Re-registering
        // a bare entry would drop both parameters and deploy a recipe configured differently from
        // every other recipe in the pack.
        Assert.Contains("envWide: 'from-environment'", bicep);
        Assert.Contains("typeScoped: 'from-type'", bicep);
    }
 
    /// <summary>
    /// The existing mode references an object the cluster already has rather than creating it, so
    /// two of those stores naming the same object cannot overwrite each other — exposing different
    /// keys from one <c>Secret</c> is the point of the mode.
    /// </summary>
    [Fact]
    public void TwoExistingSecretStoresReferencingTheSameObject_StillPublish()
    {
        var bicep = GenerateBicep(builder =>
        {
            builder.AddRadiusSecretStore("creds-user", RadiusSecretStoreType.Generic)
                .WithExistingSecret("shared-ns/shared-secret", "username");
            builder.AddRadiusSecretStore("creds-password", RadiusSecretStoreType.Generic)
                .WithExistingSecret("shared-ns/shared-secret", "password");
            builder.AddContainer("api", "myapp/api:latest");
        });
 
        Assert.Contains("resource: 'shared-ns/shared-secret'", bicep);
    }
 
    /// <summary>
    /// A generated secret does create the object, so it would overwrite what an existing store
    /// references — the reference then exposes keys the surviving object does not have.
    /// </summary>
    [Fact]
    public void ExistingSecretStoreReferencingAGeneratedSecretsObject_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(builder =>
        {
            builder.AddRadiusSecretStore("creds", RadiusSecretStoreType.Generic)
                // The environment's Kubernetes namespace, which is what the generated secret
                // deploys into — not the environment's Radius resource name.
                .WithExistingSecret("default/api-env-secret", "username");
            builder.AddContainer("Api", "myapp/api:latest")
                .WithEnvironment("PW", builder.AddParameter("pw", secret: true));
        }));
 
        Assert.Contains("ASPIRERADIUS090", ex.Message);
        Assert.Contains("api-env-secret", ex.Message);
    }
 
    /// <summary>
    /// A sealed store also carries a <c>resource</c> reference, but deploy applies its manifest with
    /// <c>kubectl apply</c>, so it creates or replaces the object rather than only reading it —
    /// classifying it with the existing mode would let it silently clobber a generated secret.
    /// </summary>
    [Fact]
    public void SealedSecretStoreNamedLikeTheGeneratedContainerSecret_FailsThePublish()
    {
        var manifest = WriteSealedManifest("api-env-secret", "default");
 
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(
            builder => builder.AddContainer("Api", "myapp/api:latest")
                .WithEnvironment("PW", builder.AddParameter("pw", secret: true)),
            configureEnvironment: radius => radius.WithSecretStore(
                "creds",
                RadiusSecretStoreType.Generic,
                s => s.WithSealedSecret(manifest, "username"))));
 
        Assert.Contains("ASPIRERADIUS090", ex.Message);
        Assert.Contains("api-env-secret", ex.Message);
    }
 
    /// <summary>
    /// One manifest split across two stores — each exposing a different key from the same file — is
    /// a legitimate, common shape, not a collision. Both deploy steps apply byte-identical validated
    /// content and <c>SealedSecretApplyStep</c>'s re-apply is deliberately idempotent, so there is
    /// nothing to clobber. <c>ASPIRERADIUS090</c> is reserved for genuinely distinct writers.
    /// </summary>
    [Fact]
    public void TwoSealedSecretStoresApplyingTheSameManifest_PublishSuccessfully()
    {
        var manifest = WriteSealedManifest("shared-secret", "default", "shared-secret", ["username", "password"]);
 
        var bicep = GenerateBicep(
            builder => builder.AddContainer("api", "myapp/api:latest"),
            configureEnvironment: radius => radius
                .WithSecretStore("creds-user", RadiusSecretStoreType.Generic, s => s.WithSealedSecret(manifest, "username"))
                .WithSecretStore("creds-password", RadiusSecretStoreType.Generic, s => s.WithSealedSecret(manifest, "password")));
 
        Assert.Contains("shared-secret", bicep, StringComparison.Ordinal);
    }
 
    /// <summary>
    /// Two <em>different</em> manifests naming one object really are two <c>kubectl apply</c> calls
    /// against that object, so the second replaces the first and the collision must still fail.
    /// </summary>
    [Fact]
    public void TwoDistinctSealedSecretManifestsApplyingTheSameObject_FailThePublish()
    {
        var first = WriteSealedManifest("shared-secret", "default", "first", ["username"]);
        var second = WriteSealedManifest("shared-secret", "default", "second", ["password"]);
 
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(
            builder => builder.AddContainer("api", "myapp/api:latest"),
            configureEnvironment: radius => radius
                .WithSecretStore("creds-user", RadiusSecretStoreType.Generic, s => s.WithSealedSecret(first, "username"))
                .WithSecretStore("creds-password", RadiusSecretStoreType.Generic, s => s.WithSealedSecret(second, "password"))));
 
        Assert.Contains("ASPIRERADIUS090", ex.Message);
        Assert.Contains("shared-secret", ex.Message);
    }
 
    /// <summary>
    /// Removing a sealed store's construct does not stop its manifest from being applied — the apply
    /// step selects sealed stores from the application model, not from the emitted constructs — so
    /// the collision has to survive the removal.
    /// </summary>
    [Fact]
    public void CallbackRemovingASealedStoreConstruct_StillDetectsTheCollision()
    {
        var manifest = WriteSealedManifest("api-env-secret", "default");
 
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(
            builder => builder.AddContainer("Api", "myapp/api:latest")
                .WithEnvironment("PW", builder.AddParameter("pw", secret: true)),
            opts => opts.SecretStores.Clear(),
            radius => radius.WithSecretStore(
                "creds",
                RadiusSecretStoreType.Generic,
                s => s.WithSealedSecret(manifest, "username"))));
 
        Assert.Contains("ASPIRERADIUS090", ex.Message);
        Assert.Contains("api-env-secret", ex.Message);
    }
 
    /// <summary>
    /// An environment-scoped <c>Radius.Security/secrets</c> resource leaves <c>ApplicationId</c>
    /// unset, but the property is non-nullable and always returns a construct — so its scope has to
    /// be decided by what it renders to. Testing the reference for null skipped every
    /// environment-scoped secret, leaving the collision undetected.
    /// </summary>
    [Fact]
    public void EnvironmentScopedSecuritySecretCollidingWithAStore_FailsThePublish()
    {
        var ex = Assert.Throws<InvalidOperationException>(() => GenerateBicep(
            builder =>
            {
                builder.AddRadiusSecretStore("shared", RadiusSecretStoreType.Generic)
                    .WithData("other", builder.AddParameter("store-pw", secret: true));
                AddContainerWithSecretEnvironment(builder);
            },
            opts =>
            {
                // Copy the environment reference from the generated (application-scoped) secret so
                // the new one lands in the same namespace as the colliding store.
                var scoped = opts.SecuritySecrets[0];
                var envSecret = new RadiusSecuritySecretConstruct("env_scoped_secret")
                {
                    SecretName = "shared",
                    EnvironmentId = scoped.EnvironmentId,
                };
 
                opts.SecuritySecrets.Add(envSecret);
            }));
 
        Assert.Contains("ASPIRERADIUS090", ex.Message);
        Assert.Contains("shared", ex.Message);
    }
}