File: Secrets\SecretStoreValidationTests.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 under test.
#pragma warning disable ASPIREPIPELINES001
 
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Radius.Publishing;
using Aspire.Hosting.Radius.Secrets;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
 
namespace Aspire.Hosting.Radius.Tests.Secrets;
 
public class SecretStoreValidationTests : IDisposable
{
    // Use a securely created temp subdirectory (repo guidance) instead of writing scratch manifests
    // under the test assembly directory, which may be read-only in packaged runs and can collide
    // across concurrently executing tests.
    private readonly string _sealedManifestDirectory = Directory.CreateTempSubdirectory("radius-secret-store-validation-tests").FullName;
 
    public void Dispose()
    {
        if (Directory.Exists(_sealedManifestDirectory))
        {
            Directory.Delete(_sealedManifestDirectory, recursive: true);
        }
    }
 
    private static void WithModel(
        Action<IDistributedApplicationBuilder> configure,
        Action<DistributedApplicationModel> assert)
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        configure(builder);
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        assert(model);
    }
 
    private static string Validate(DistributedApplicationModel model)
    {
        var ex = Assert.Throws<InvalidOperationException>(() => RadiusSecretStoreValidation.Validate(model));
        return ex.Message;
    }
 
    private static void BuildOptions(DistributedApplicationModel model)
    {
        var radiusEnv = model.Resources.OfType<RadiusEnvironmentResource>().First();
        RadiusTestHelper.AttachDeploymentTargets(radiusEnv, model);
        _ = new RadiusBicepPublishingContext(radiusEnv).BuildOptions(model);
    }
 
    private string WriteSealedSecretManifest(string? namespaceName)
    {
        var path = Path.Combine(_sealedManifestDirectory, $"{Guid.NewGuid():N}.sealed.yaml");
        var namespaceYaml = namespaceName is null
            ? string.Empty
            : $"  namespace: {namespaceName}\n";
 
        File.WriteAllText(
            path,
            "apiVersion: bitnami.com/v1alpha1\n" +
            "kind: SealedSecret\n" +
            "metadata:\n" +
            "  name: db-creds\n" +
            namespaceYaml +
            "spec:\n" +
            "  encryptedData:\n" +
            "    password: AgBjaXBoZXI=\n");
 
        return path;
    }
 
    [Fact]
    public void NoStores_IsNoOp()
    {
        WithModel(
            b =>
            {
                b.AddRadiusEnvironment("radius");
                b.AddContainer("api", "img", "latest");
            },
            RadiusSecretStoreValidation.Validate); // no throw
    }
 
    [Fact]
    public void AppScopedStore_WithoutEnvironment_Throws_ASPIRERADIUS068()
    {
        WithModel(
            b => b.AddRadiusSecretStore("appsecrets", RadiusSecretStoreType.Generic)
                  .WithExistingSecret("app/appsecrets", "k"),
            model =>
            {
                var ex = Assert.Throws<InvalidOperationException>(
                    () => RadiusSecretStoreValidation.ValidateHasEnvironment(model));
                Assert.Contains("ASPIRERADIUS068", ex.Message);
                Assert.Contains("appsecrets", ex.Message);
            });
    }
 
    [Fact]
    public void AppScopedStore_WithEnvironment_HasEnvironmentIsNoOp()
    {
        WithModel(
            b =>
            {
                b.AddRadiusEnvironment("radius");
                b.AddRadiusSecretStore("appsecrets", RadiusSecretStoreType.Generic)
                 .WithExistingSecret("app/appsecrets", "k");
            },
            RadiusSecretStoreValidation.ValidateHasEnvironment); // no throw
    }
 
    [Fact]
    public async Task AppScopedStore_WithoutEnvironment_RegistersValidationGate()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var store = builder.AddRadiusSecretStore("appsecrets", RadiusSecretStoreType.Generic)
            .WithExistingSecret("app/appsecrets", "k");
 
        // The gate is registered on the store resource itself (not an environment) so it runs even
        // when the model has no Radius environment; verify the pipeline step is present and wired up.
        var annotation = Assert.Single(store.Resource.Annotations.OfType<PipelineStepAnnotation>());
        var steps = (await annotation.CreateStepsAsync(new PipelineStepFactoryContext
        {
            PipelineContext = null!,
            Resource = store.Resource,
        })).ToList();
        var gate = Assert.Single(steps);
        Assert.Equal("validate-radius-app-scoped-store-appsecrets", gate.Name);
        Assert.Contains("publish", gate.RequiredBySteps);
        Assert.Contains("deploy", gate.RequiredBySteps);
    }
 
    [Fact]
    public void MissingRequiredKey_Throws_ASPIRERADIUS040()
    {
        WithModel(
            b =>
            {
                b.AddRadiusEnvironment("radius");
                // certificate requires tls.crt and tls.key; only tls.crt is declared.
                b.AddRadiusSecretStore("tls", RadiusSecretStoreType.Certificate)
                    .WithExistingSecret("app/tls", "tls.crt");
            },
            m => Assert.Contains("ASPIRERADIUS040", Validate(m)));
    }
 
    [Fact]
    public void NoPopulationMode_Throws_ASPIRERADIUS041()
    {
        WithModel(
            b =>
            {
                b.AddRadiusEnvironment("radius");
                b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic);
            },
            m => Assert.Contains("ASPIRERADIUS041", Validate(m)));
    }
 
    [Fact]
    public void BothPopulationModes_Throw_ASPIRERADIUS065()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var p = builder.AddParameter("p", secret: true);
        builder.AddRadiusEnvironment("radius");
        var store = builder.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic)
            .WithData(d => d.Add("k", p));
 
        // A second population call (cross-mode here) is now rejected at the call site with 065,
        // before the validation gate runs, so misuse fails immediately with a clear stack trace.
        var ex = Assert.Throws<InvalidOperationException>(() => store.WithExistingSecret("app/s", "k"));
        Assert.Contains("ASPIRERADIUS065", ex.Message);
    }
 
    [Fact]
    public void NonSecretInlineBinding_Throws_ASPIRERADIUS042()
    {
        WithModel(
            b =>
            {
                var notSecret = b.AddParameter("plain");
                b.AddRadiusEnvironment("radius");
                b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic)
                    .WithData(d => d.Add("k", notSecret));
            },
            m => Assert.Contains("ASPIRERADIUS042", Validate(m)));
    }
 
    [Fact]
    public void DuplicateKey_Throws_ASPIRERADIUS043()
    {
        WithModel(
            b =>
            {
                b.AddRadiusEnvironment("radius");
                b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic)
                    .WithExistingSecret("app/s", "dup", "dup");
            },
            m => Assert.Contains("ASPIRERADIUS043", Validate(m)));
    }
 
    [Fact]
    public void RawEncodingOnCertificate_Throws_ASPIRERADIUS047()
    {
        WithModel(
            b =>
            {
                var crt = b.AddParameter("crt", secret: true);
                var key = b.AddParameter("key", secret: true);
                b.AddRadiusEnvironment("radius");
                b.AddRadiusSecretStore("tls", RadiusSecretStoreType.Certificate)
                    .WithData(d =>
                    {
                        d.Add("tls.crt", crt, encoding: RadiusSecretStoreEncoding.Raw);
                        d.Add("tls.key", key);
                    });
            },
            m => Assert.Contains("ASPIRERADIUS047", Validate(m)));
    }
 
    [Fact]
    public void DuplicateStoreName_IsRejectedByModel()
    {
        // Aspire enforces unique, grammar-restricted resource names, so a duplicate store name
        // can never reach the ASPIRERADIUS048 gate — the model rejects it at Add time. The gate
        // remains as a defensive check for any future routing path that bypasses the model.
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var p = builder.AddParameter("p", secret: true);
        builder.AddRadiusEnvironment("radius");
        builder.AddRadiusSecretStore("db-creds", RadiusSecretStoreType.Generic).WithData(d => d.Add("k", p));
 
        Assert.ThrowsAny<Exception>(() =>
            builder.AddRadiusSecretStore("db-creds", RadiusSecretStoreType.Generic));
    }
 
    [Fact]
    public void ConsumerKindIncompatibleWithStoreType_Throws_ASPIRERADIUS051()
    {
        WithModel(
            b =>
            {
                var env = b.AddRadiusEnvironment("radius");
                // Bicep-registry auth requires a basicAuthentication store; a Generic store is incompatible.
                var store = b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic)
                    .WithExistingSecret("app/s", "k");
                env.WithBicepRegistryAuthentication("myregistry.azurecr.io", store);
            },
            m => Assert.Contains("ASPIRERADIUS051", Validate(m)));
    }
 
    [Fact]
    public void TerraformGitPatStoreMissingPatKey_Throws_ASPIRERADIUS051()
    {
        WithModel(
            b =>
            {
                var env = b.AddRadiusEnvironment("radius");
                var user = b.AddParameter("u", secret: true);
                var pass = b.AddParameter("p", secret: true);
                // A basicAuthentication (username/password) store has no 'pat' key, so Radius cannot
                // obtain a PAT for Terraform Git auth.
                var store = b.AddRadiusSecretStore("git-creds", RadiusSecretStoreType.BasicAuthentication)
                    .WithData(d => { d.Add("username", user); d.Add("password", pass); });
                env.WithTerraformGitAuthentication("github.com", store);
            },
            m => Assert.Contains("ASPIRERADIUS051", Validate(m)));
    }
 
    [Fact]
    public void TerraformGitPatStoreWithPatKey_IsValid()
    {
        WithModel(
            b =>
            {
                var env = b.AddRadiusEnvironment("radius");
                var user = b.AddParameter("u", secret: true);
                var pat = b.AddParameter("pat", secret: true);
                var store = b.AddRadiusSecretStore("git-creds", RadiusSecretStoreType.Generic)
                    .WithData(d => { d.Add("username", user); d.Add("pat", pat); });
                env.WithTerraformGitAuthentication("github.com", store);
            },
                RadiusSecretStoreValidation.Validate);
    }
 
    [Fact]
    public void EnvSecretReferencesUndeclaredKey_Throws_ASPIRERADIUS052()
    {
        WithModel(
            b =>
            {
                var pass = b.AddParameter("p", secret: true);
                var env = b.AddRadiusEnvironment("radius");
                var store = b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic)
                    .WithData(d => d.Add("password", pass));
                // 'missing' is not a declared key on the store.
                env.WithRecipeEnvironmentSecret("DB_PASSWORD", store, "missing");
            },
            m => Assert.Contains("ASPIRERADIUS052", Validate(m)));
    }
 
    [Fact]
    public void MaterializationTimeoutOnNonSealedStore_Throws_ASPIRERADIUS062()
    {
        WithModel(
            b =>
            {
                var p = b.AddParameter("p", secret: true);
                b.AddRadiusEnvironment("radius");
                b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic)
                    .WithData(d => d.Add("k", p))
                    .WithMaterializationTimeout(TimeSpan.FromSeconds(30));
            },
            m => Assert.Contains("ASPIRERADIUS062", Validate(m)));
    }
 
    [Fact]
    public void KeylessStoreWithKeySpecificEnvSecret_Throws_ASPIRERADIUS064()
    {
        WithModel(
            b =>
            {
                var env = b.AddRadiusEnvironment("radius");
                // Keyless existing store: WithExistingSecret with no key list declares no keys, so a
                // key-specific envSecrets consumer against it would emit a dangling reference.
                var store = b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic)
                    .WithExistingSecret("prod/my-secret");
                env.WithRecipeEnvironmentSecret("DB_PASSWORD", store, "password");
            },
            m => Assert.Contains("ASPIRERADIUS064", Validate(m)));
    }
 
    [Fact]
    public void KeylessStoreWithoutKeySpecificConsumer_IsAllowed()
    {
        WithModel(
            b =>
            {
                b.AddRadiusEnvironment("radius");
                // A keyless existing store that no key-specific envSecrets consumer references
                // materializes its keys out-of-band and is intentionally left unchecked.
                b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic)
                    .WithExistingSecret("prod/my-secret");
            },
            RadiusSecretStoreValidation.Validate); // no throw
    }
 
    [Fact]
    public void ApplicationScopedBareExistingSecretReference_Throws_ASPIRERADIUS055()
    {
        WithModel(
            b =>
            {
                b.AddRadiusEnvironment("radius");
                // Application-scoped store with a bare '<name>' reference has no owning environment
                // to default the namespace from.
                b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic)
                    .WithExistingSecret("bare-name", "k");
            },
            m => Assert.Contains("ASPIRERADIUS055", Validate(m)));
    }
 
    [Fact]
    public void ApplicationScopedSealedSecretWithoutExplicitNamespace_Throws_ASPIRERADIUS055()
    {
        var manifestPath = WriteSealedSecretManifest(namespaceName: null);
 
        WithModel(
            b =>
            {
                b.AddRadiusEnvironment("radius");
                b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic)
                    .WithSealedSecret(manifestPath, "password");
            },
            m =>
            {
                var ex = Assert.Throws<InvalidOperationException>(() => BuildOptions(m));
                Assert.Contains("ASPIRERADIUS055", ex.Message);
                Assert.Contains("metadata.namespace", ex.Message);
            });
    }
 
    [Fact]
    public void ApplicationScopedSealedSecretWithExplicitNamespace_IsAllowed()
    {
        var manifestPath = WriteSealedSecretManifest("prod");
 
        WithModel(
            b =>
            {
                b.AddRadiusEnvironment("radius");
                b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic)
                    .WithSealedSecret(manifestPath, "password");
            },
            BuildOptions);
    }
 
    [Fact]
    public void EnvironmentScopedSealedSecretWithoutExplicitNamespace_IsAllowed()
    {
        var manifestPath = WriteSealedSecretManifest(namespaceName: null);
 
        WithModel(
            b =>
            {
                var env = b.AddRadiusEnvironment("radius");
                env.WithSecretStore("s", RadiusSecretStoreType.Generic, s =>
                    s.WithSealedSecret(manifestPath, "password"));
            },
            BuildOptions);
    }
 
    [Fact]
    public void ApplicationScopedQualifiedExistingSecretReference_IsAllowed()
    {
        WithModel(
            b =>
            {
                b.AddRadiusEnvironment("radius");
                b.AddRadiusSecretStore("s", RadiusSecretStoreType.Generic)
                    .WithExistingSecret("prod/my-secret", "k");
            },
            RadiusSecretStoreValidation.Validate); // no throw
    }
 
    [Fact]
    public void ApplicationScopedStoreCollidesWithEnvironmentScopedIdentifier_Throws_ASPIRERADIUS048()
    {
        WithModel(
            b =>
            {
                var env = b.AddRadiusEnvironment("env1");
                env.WithSecretStore("radius", RadiusSecretStoreType.Generic, s =>
                    s.WithExistingSecret("db-creds", "password"));
                b.AddRadiusSecretStore("radiusenv", RadiusSecretStoreType.Generic)
                    .WithExistingSecret("prod/db-creds", "password");
            },
            m =>
            {
                var message = Validate(m);
                Assert.Contains("ASPIRERADIUS048", message);
                Assert.EndsWith("Diagnostic: ASPIRERADIUS048.", message);
            });
    }
 
    [Fact]
    public void EnvironmentScopedStoresInDifferentEnvironmentsMayShareIdentifier()
    {
        WithModel(
            b =>
            {
                var dev = b.AddRadiusEnvironment("dev");
                var prod = b.AddRadiusEnvironment("prod");
                dev.WithSecretStore("radius", RadiusSecretStoreType.Generic, s =>
                    s.WithExistingSecret("db-creds", "password"));
                prod.WithSecretStore("radiusenv", RadiusSecretStoreType.Generic, s =>
                    s.WithExistingSecret("db-creds", "password"));
            },
            RadiusSecretStoreValidation.Validate);
    }
}