File: UserSecretsParameterDefaultTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Reflection;
using System.Reflection.Emit;
using System.Text;
using Aspire.Hosting.Pipelines.Internal;
using Aspire.Hosting.Publishing;
using Aspire.Hosting.UserSecrets;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.UserSecrets;
 
namespace Aspire.Hosting.Tests;
 
public class UserSecretsParameterDefaultTests
{
    private static readonly ConstructorInfo s_userSecretsIdAttrCtor = typeof(UserSecretsIdAttribute).GetConstructor([typeof(string)])!;
 
    [Fact]
    public void UserSecretsParameterDefault_GetDefaultValue_SavesValueInAppHostUserSecrets()
    {
        var userSecretsId = Guid.NewGuid().ToString("N");
        ClearUsersSecrets(userSecretsId);
 
        var testAssembly = AssemblyBuilder.DefineDynamicAssembly(
            new("TestAssembly"), AssemblyBuilderAccess.RunAndCollect, [new CustomAttributeBuilder(s_userSecretsIdAttrCtor, [userSecretsId])]);
        var paramDefault = new TestParameterDefault();
        var userSecretDefault = new UserSecretsParameterDefault(testAssembly, "TestApplication.AppHost", "param1", paramDefault);
        var initialValue = userSecretDefault.GetDefaultValue();
 
        var userSecrets = GetUserSecrets(userSecretsId);
        Assert.Equal(initialValue, userSecrets["Parameters:param1"]);
 
        DeleteUserSecretsFile(userSecretsId);
    }
 
    [Fact]
    public void UserSecretsParameterDefault_GetDefaultValue_DoesntThrowIfValueCantBeSaved()
    {
        // Do not set a user secrets id attribute on the assembly
        var testAssembly = AssemblyBuilder.DefineDynamicAssembly(new("TestAssembly"), AssemblyBuilderAccess.RunAndCollect, []);
        var paramDefault = new TestParameterDefault();
        var userSecretDefault = new UserSecretsParameterDefault(testAssembly, "TestApplication.AppHost", "param1", paramDefault);
 
        var initialValue = userSecretDefault.GetDefaultValue();
        Assert.NotNull(initialValue);
    }
 
    [Fact]
    public void UserSecretsParameterDefault_GetDefaultValue_DoesntThrowIfSecretsFileContainsComments()
    {
        var userSecretsId = Guid.NewGuid().ToString("N");
        DeleteUserSecretsFile(userSecretsId);
        var userSecretsPath = UserSecretsPathHelper.GetSecretsPathFromSecretsId(userSecretsId);
        if (File.Exists(userSecretsPath))
        {
            File.Delete(userSecretsPath);
        }
        var secretsFileContents = """
            {
                // This is a comment in a JSON file
                "SomeConfigKey": "some value"
            }
            """;
        EnsureUserSecretsDirectory(userSecretsPath);
        File.WriteAllText(userSecretsPath, secretsFileContents, Encoding.UTF8);
 
        var testAssembly = AssemblyBuilder.DefineDynamicAssembly(
            new("TestAssembly"), AssemblyBuilderAccess.RunAndCollect, [new CustomAttributeBuilder(s_userSecretsIdAttrCtor, [userSecretsId])]);
        var paramDefault = new TestParameterDefault();
        var userSecretDefault = new UserSecretsParameterDefault(testAssembly, "TestApplication.AppHost", "param1", paramDefault);
 
        var _ = userSecretDefault.GetDefaultValue();
    }
 
    [Fact]
    public async Task TrySetUserSecret_ConcurrentWrites_PreservesAllSecrets()
    {
        var userSecretsId = Guid.NewGuid().ToString("N");
        ClearUsersSecrets(userSecretsId);
 
        var testAssembly = AssemblyBuilder.DefineDynamicAssembly(
            new("TestAssembly"), AssemblyBuilderAccess.RunAndCollect, [new CustomAttributeBuilder(s_userSecretsIdAttrCtor, [userSecretsId])]);
 
        // Create an isolated factory instance for this test to avoid cross-contamination
        var factory = new UserSecretsManagerFactory();
 
        // Simulate concurrent writes from multiple threads (like SQL Server and RabbitMQ generating passwords)
        var tasks = new List<Task<bool>>();
        var secretsToWrite = new Dictionary<string, string>
        {
            ["Parameters:sqlserver-password"] = "SqlPassword123!",
            ["Parameters:rabbitmq-password"] = "RabbitPassword456!",
            ["Parameters:redis-password"] = "RedisPassword789!",
            ["Parameters:postgres-password"] = "PostgresPassword012!",
        };
 
        foreach (var kvp in secretsToWrite)
        {
            var key = kvp.Key;
            var value = kvp.Value;
            tasks.Add(Task.Run(() =>
            {
                var manager = factory.GetOrCreate(testAssembly);
                return manager?.TrySetSecret(key, value) ?? false;
            }));
        }
 
        var results = await Task.WhenAll(tasks);
 
        // All writes should succeed
        Assert.All(results, Assert.True);
 
        // All secrets should be preserved
        var userSecrets = GetUserSecrets(userSecretsId);
        foreach (var kvp in secretsToWrite)
        {
            Assert.True(userSecrets.ContainsKey(kvp.Key), $"Secret '{kvp.Key}' was not found in user secrets");
            Assert.Equal(kvp.Value, userSecrets[kvp.Key]);
        }
 
        DeleteUserSecretsFile(userSecretsId);
    }
 
    [Fact]
    public async Task TrySetUserSecret_SqlServerAndRabbitMQ_BothSecretsPreserved()
    {
        // This test specifically reproduces the issue described in the bug report
        var userSecretsId = Guid.NewGuid().ToString("N");
        ClearUsersSecrets(userSecretsId);
 
        var testAssembly = AssemblyBuilder.DefineDynamicAssembly(
            new("TestAssembly"), AssemblyBuilderAccess.RunAndCollect, [new CustomAttributeBuilder(s_userSecretsIdAttrCtor, [userSecretsId])]);
 
        // Create an isolated factory instance for this test to avoid cross-contamination
        var factory = new UserSecretsManagerFactory();
 
        // Simulate SQL Server and RabbitMQ generating passwords concurrently
        var sqlTask = Task.Run(() =>
        {
            var manager = factory.GetOrCreate(testAssembly);
            return manager?.TrySetSecret("Parameters:sql-password", "SqlPassword123!") ?? false;
        });
        var rabbitTask = Task.Run(() =>
        {
            var manager = factory.GetOrCreate(testAssembly);
            return manager?.TrySetSecret("Parameters:rabbit-password", "RabbitPassword456!") ?? false;
        });
 
        var results = await Task.WhenAll(sqlTask, rabbitTask);
 
        // Both writes should succeed
        Assert.All(results, Assert.True);
 
        // Both secrets should be in the file
        var userSecrets = GetUserSecrets(userSecretsId);
        Assert.True(userSecrets.ContainsKey("Parameters:sql-password"), "SQL Server password was not found");
        Assert.True(userSecrets.ContainsKey("Parameters:rabbit-password"), "RabbitMQ password was not found");
        Assert.Equal("SqlPassword123!", userSecrets["Parameters:sql-password"]);
        Assert.Equal("RabbitPassword456!", userSecrets["Parameters:rabbit-password"]);
 
        DeleteUserSecretsFile(userSecretsId);
    }
 
    [Fact]
    public async Task TrySetUserSecret_ConcurrentWritesSameKey_LastWriteWins()
    {
        var userSecretsId = Guid.NewGuid().ToString("N");
        ClearUsersSecrets(userSecretsId);
 
        var testAssembly = AssemblyBuilder.DefineDynamicAssembly(
            new("TestAssembly"), AssemblyBuilderAccess.RunAndCollect, [new CustomAttributeBuilder(s_userSecretsIdAttrCtor, [userSecretsId])]);
 
        // Create an isolated factory instance for this test to avoid cross-contamination
        var factory = new UserSecretsManagerFactory();
 
        // Simulate concurrent writes to the same key
        var tasks = new List<Task<bool>>();
        for (int i = 0; i < 10; i++)
        {
            var value = $"Value{i}";
            tasks.Add(Task.Run(() =>
            {
                var manager = factory.GetOrCreate(testAssembly);
                return manager?.TrySetSecret("Parameters:test-key", value) ?? false;
            }));
        }
 
        var results = await Task.WhenAll(tasks);
 
        // All writes should succeed
        Assert.All(results, Assert.True);
 
        // The key should exist with one of the values
        var userSecrets = GetUserSecrets(userSecretsId);
        Assert.True(userSecrets.ContainsKey("Parameters:test-key"));
        Assert.NotNull(userSecrets["Parameters:test-key"]);
 
        DeleteUserSecretsFile(userSecretsId);
    }
 
    [Fact]
    public void UserSecretsParameterDefault_WithCustomFactory_UsesProvidedFactory()
    {
        // This test verifies that the constructor overload taking a factory parameter
        // uses the provided factory instead of the singleton instance
        var userSecretsId = Guid.NewGuid().ToString("N");
        ClearUsersSecrets(userSecretsId);
 
        var testAssembly = AssemblyBuilder.DefineDynamicAssembly(
            new("TestAssembly"), AssemblyBuilderAccess.RunAndCollect, [new CustomAttributeBuilder(s_userSecretsIdAttrCtor, [userSecretsId])]);
 
        // Create a custom factory instance for test isolation
        var customFactory = new UserSecretsManagerFactory();
        var paramDefault = new TestParameterDefault();
        var userSecretDefault = new UserSecretsParameterDefault(testAssembly, "TestApplication.AppHost", "param1", paramDefault, customFactory);
 
        var initialValue = userSecretDefault.GetDefaultValue();
 
        var userSecrets = GetUserSecrets(userSecretsId);
        Assert.Equal(initialValue, userSecrets["Parameters:param1"]);
 
        DeleteUserSecretsFile(userSecretsId);
    }
 
    [Fact]
    public void UserSecretsParameterDefault_WithCustomFactory_IsolatesFromGlobalInstance()
    {
        // This test verifies that using a custom factory provides isolation
        // between test runs and doesn't interfere with the singleton instance
        var userSecretsId1 = Guid.NewGuid().ToString("N");
        var userSecretsId2 = Guid.NewGuid().ToString("N");
        ClearUsersSecrets(userSecretsId1);
        ClearUsersSecrets(userSecretsId2);
 
        var testAssembly1 = AssemblyBuilder.DefineDynamicAssembly(
            new("TestAssembly1"), AssemblyBuilderAccess.RunAndCollect, [new CustomAttributeBuilder(s_userSecretsIdAttrCtor, [userSecretsId1])]);
        var testAssembly2 = AssemblyBuilder.DefineDynamicAssembly(
            new("TestAssembly2"), AssemblyBuilderAccess.RunAndCollect, [new CustomAttributeBuilder(s_userSecretsIdAttrCtor, [userSecretsId2])]);
 
        // Use custom factory for first parameter default
        var customFactory = new UserSecretsManagerFactory();
        var paramDefault1 = new TestParameterDefault();
        var userSecretDefault1 = new UserSecretsParameterDefault(testAssembly1, "TestApp1.AppHost", "param1", paramDefault1, customFactory);
 
        // Use default singleton factory for second parameter default
        var paramDefault2 = new TestParameterDefault();
        var userSecretDefault2 = new UserSecretsParameterDefault(testAssembly2, "TestApp2.AppHost", "param2", paramDefault2);
 
        var value1 = userSecretDefault1.GetDefaultValue();
        var value2 = userSecretDefault2.GetDefaultValue();
 
        // Both should save successfully to their respective user secrets files
        var userSecrets1 = GetUserSecrets(userSecretsId1);
        var userSecrets2 = GetUserSecrets(userSecretsId2);
 
        Assert.Equal(value1, userSecrets1["Parameters:param1"]);
        Assert.Equal(value2, userSecrets2["Parameters:param2"]);
 
        DeleteUserSecretsFile(userSecretsId1);
        DeleteUserSecretsFile(userSecretsId2);
    }
 
    [Fact]
    public async Task UserSecretsParameterDefault_WithCustomFactory_ConcurrentAccess()
    {
        // This test verifies that the custom factory properly handles concurrent access
        var userSecretsId = Guid.NewGuid().ToString("N");
        ClearUsersSecrets(userSecretsId);
 
        var testAssembly = AssemblyBuilder.DefineDynamicAssembly(
            new("TestAssembly"), AssemblyBuilderAccess.RunAndCollect, [new CustomAttributeBuilder(s_userSecretsIdAttrCtor, [userSecretsId])]);
 
        var customFactory = new UserSecretsManagerFactory();
 
        // Create multiple UserSecretsParameterDefault instances with different parameter names
        var tasks = new List<Task<string>>();
        for (int i = 0; i < 5; i++)
        {
            var paramName = $"param{i}";
            tasks.Add(Task.Run(() =>
            {
                var paramDefault = new TestParameterDefault();
                var userSecretDefault = new UserSecretsParameterDefault(testAssembly, "TestApp.AppHost", paramName, paramDefault, customFactory);
                return userSecretDefault.GetDefaultValue();
            }));
        }
 
        var values = await Task.WhenAll(tasks);
 
        // All parameters should be saved
        var userSecrets = GetUserSecrets(userSecretsId);
        for (int i = 0; i < 5; i++)
        {
            var paramKey = $"Parameters:param{i}";
            Assert.True(userSecrets.ContainsKey(paramKey), $"Parameter '{paramKey}' was not found in user secrets");
            Assert.Equal(values[i], userSecrets[paramKey]);
        }
 
        DeleteUserSecretsFile(userSecretsId);
    }
 
    private static void EnsureUserSecretsDirectory(string secretsFilePath)
    {
        var directoryName = Path.GetDirectoryName(secretsFilePath);
        if (!string.IsNullOrEmpty(directoryName) && !Directory.Exists(directoryName))
        {
            Directory.CreateDirectory(directoryName);
        }
    }
 
    private static Dictionary<string, string?> GetUserSecrets(string userSecretsId)
    {
        var manager = UserSecretsManagerFactory.Instance.GetOrCreateFromId(userSecretsId);
        
        // Read the secrets file directly
        var secrets = new Dictionary<string, string?>();
        if (File.Exists(manager.FilePath))
        {
            var json = File.ReadAllText(manager.FilePath);
            if (!string.IsNullOrWhiteSpace(json))
            {
                var config = new ConfigurationBuilder()
                    .AddJsonFile(manager.FilePath, optional: true)
                    .Build();
                    
                foreach (var kvp in config.AsEnumerable())
                {
                    if (kvp.Value != null)
                    {
                        secrets[kvp.Key] = kvp.Value;
                    }
                }
            }
        }
        return secrets;
    }
 
    private static void ClearUsersSecrets(string userSecretsId)
    {
        var filePath = UserSecretsPathHelper.GetSecretsPathFromSecretsId(userSecretsId);
        if (File.Exists(filePath))
        {
            File.Delete(filePath);
        }
    }
 
    private static void DeleteUserSecretsFile(string userSecretsId)
    {
        var userSecretsPath = UserSecretsPathHelper.GetSecretsPathFromSecretsId(userSecretsId);
        if (File.Exists(userSecretsPath))
        {
            File.Delete(userSecretsPath);
        }
    }
 
    private sealed class TestParameterDefault : ParameterDefault
    {
        public override string GetDefaultValue()
        {
            return Guid.NewGuid().ToString("N");
        }
 
        public override void WriteToManifest(ManifestPublishingContext context)
        {
            throw new NotImplementedException();
        }
    }
}