From 7c501fdf55baf5252faa2f43c8667e36c403d00e Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:52:58 -0500 Subject: [PATCH] feat: implement config builder, source, and extensions --- .../Configuration/ISecureConfigBuilder.cs | 77 +- .../Configuration/SecureConfigBuilder.cs | 103 +++ .../Configuration/SecureConfigSource.cs | 29 + .../SecureConfigExtensions.cs | 134 ++++ ...tensions.Configuration.Secure.Tests.csproj | 2 +- .../Configuration/SecureConfigBuilderTests.cs | 331 ++++++++ .../SecureConfigProviderTests.cs | 10 +- .../Configuration/SecureConfigSourceTests.cs | 140 ++++ .../Unit/Configuration/SecureConfigTests.cs | 119 ++- .../Unit/SecureConfigExtensionsTests.cs | 748 ++++++++++++++++++ 10 files changed, 1664 insertions(+), 29 deletions(-) create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigBuilder.cs create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigSource.cs create mode 100644 src/StevanFreeborn.Extensions.Configuration.Secure/SecureConfigExtensions.cs create mode 100644 tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigBuilderTests.cs create mode 100644 tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigSourceTests.cs create mode 100644 tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/SecureConfigExtensionsTests.cs diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfigBuilder.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfigBuilder.cs index f940423..d8ff72d 100644 --- a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfigBuilder.cs +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/ISecureConfigBuilder.cs @@ -1,14 +1,83 @@ -using Microsoft.Extensions.DependencyInjection; +using System.Text.Json.Serialization.Metadata; + +using Microsoft.Extensions.Logging; + +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; +using StevanFreeborn.Extensions.Configuration.Secure.Storage; namespace StevanFreeborn.Extensions.Configuration.Secure.Configuration; /// -/// Provides a builder interface for configuring secure configuration services. +/// Provides a fluent builder interface for configuring secure configuration storage and encryption. /// public interface ISecureConfigBuilder { /// - /// Gets the used to register secure configuration services. + /// Configures JSON file-based storage using the provided options instance. /// - IServiceCollection Services { get; } + /// The JSON storage configuration options. + /// The current instance for method chaining. + ISecureConfigBuilder UseJsonFileStorage(JsonStorageOptions options); + + /// + /// Configures JSON file-based storage using an action to configure the options. + /// + /// An action to configure the . + /// The current instance for method chaining. + ISecureConfigBuilder UseJsonFileStorage(Action configure); + + /// + /// Registers a JSON AOT source-generated context for serializing complex types. + /// + /// A context that implements that will be used for JSON serialization. + /// The current instance for method chaining. + ISecureConfigBuilder AddJsonAotContext(IJsonTypeInfoResolver context); + + /// + /// Configures a custom storage provider for secure configuration data. + /// + /// The custom storage provider implementation. + /// The current instance for method chaining. + ISecureConfigBuilder UseCustomStorage(ISecureStorageProvider provider); + + /// + /// Configures encryption using a Base64-encoded encryption key. + /// + /// The Base64-encoded encryption key string. + /// The current instance for method chaining. + ISecureConfigBuilder WithBase64EncryptionKey(string key); + + /// + /// Configures encryption using a key derived from the machine id. + /// + /// The current instance for method chaining. + ISecureConfigBuilder WithMachineIdKey(); + + + /// + /// Configures a custom encryption key provider. + /// + /// The custom encryption key provider implementation. + /// The current instance for method chaining. + ISecureConfigBuilder WithCustomKeyProvider(IEncryptionKeyProvider keyProvider); + + /// + /// Configures logging using the provided logger factory. + /// + /// The logger factory to use for logging operations. + /// The current instance for method chaining. + ISecureConfigBuilder WithLoggerFactory(ILoggerFactory loggerFactory); + + /// + /// Configures AES crypto provider for encryption and decryption + /// + /// The current instance for method chaining. + ISecureConfigBuilder WithAesCryptoProvider(); + + /// + /// Configures the factory function that will be used to create the crypto provider for encryption and decryption + /// + /// The crypto provider factor to use for encryption and decryption operations. + /// The current instance for method chaining. + ISecureConfigBuilder WithCustomCryptoProvider(Func cryptoProviderFactory); } \ No newline at end of file diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigBuilder.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigBuilder.cs new file mode 100644 index 0000000..cba3650 --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigBuilder.cs @@ -0,0 +1,103 @@ +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; +using StevanFreeborn.Extensions.Configuration.Secure.Storage; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Configuration; + +internal sealed class SecureConfigBuilder : ISecureConfigBuilder +{ + internal ISecureStorageProvider? StorageProvider { get; private set; } + internal Func? CryptoProviderFactory { get; private set; } + internal IEncryptionKeyProvider? KeyProvider { get; private set; } + internal ILoggerFactory LoggerFactory { get; private set; } = NullLoggerFactory.Instance; + internal JsonSerializerOptions SerializerOptions { get; } = new() + { + PropertyNameCaseInsensitive = true, + }; + + public ISecureConfigBuilder UseJsonFileStorage(JsonStorageOptions options) + { + StorageProvider = new JsonFileStorageProvider(options); + return this; + } + + public ISecureConfigBuilder UseJsonFileStorage(Action configure) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(configure); +#else + if (configure is null) + { + throw new ArgumentNullException(nameof(configure)); + } +#endif + + var options = new JsonStorageOptions(); + configure.Invoke(options); + StorageProvider = new JsonFileStorageProvider(options); + return this; + } + + public ISecureConfigBuilder AddJsonAotContext(IJsonTypeInfoResolver context) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(context); +#else + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } +#endif + + SerializerOptions.TypeInfoResolverChain.Insert(0, context); + return this; + } + + public ISecureConfigBuilder UseCustomStorage(ISecureStorageProvider provider) + { + StorageProvider = provider ?? throw new ArgumentNullException(nameof(provider)); + return this; + } + + public ISecureConfigBuilder WithBase64EncryptionKey(string key) + { + KeyProvider = new StaticKeyProvider(key); + return this; + } + + public ISecureConfigBuilder WithMachineIdKey() + { + var logger = LoggerFactory.CreateLogger(); + KeyProvider = new MachineIdKeyProvider(new MachineIdKeyGenerator(logger)); + return this; + } + + public ISecureConfigBuilder WithCustomKeyProvider(IEncryptionKeyProvider provider) + { + KeyProvider = provider ?? throw new ArgumentNullException(nameof(provider)); + return this; + } + + public ISecureConfigBuilder WithLoggerFactory(ILoggerFactory loggerFactory) + { + LoggerFactory = loggerFactory ?? NullLoggerFactory.Instance; + return this; + } + + public ISecureConfigBuilder WithAesCryptoProvider() + { + CryptoProviderFactory = (kp) => new AesCryptoProvider(kp); + return this; + } + + public ISecureConfigBuilder WithCustomCryptoProvider(Func cryptoProviderFactory) + { + CryptoProviderFactory = cryptoProviderFactory; + return this; + } +} \ No newline at end of file diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigSource.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigSource.cs new file mode 100644 index 0000000..b5b1088 --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigSource.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; +using StevanFreeborn.Extensions.Configuration.Secure.Storage; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Configuration; + +internal sealed class SecureConfigSource( + ISecureStorageProvider storageProvider, + ICryptoProvider cryptoProvider, + ILoggerFactory loggerFactory +) : IConfigurationSource +{ + private readonly ISecureStorageProvider _storageProvider = storageProvider + ?? throw new ArgumentNullException(nameof(storageProvider)); + + private readonly ICryptoProvider _cryptoProvider = cryptoProvider + ?? throw new ArgumentNullException(nameof(cryptoProvider)); + + private readonly ILoggerFactory _loggerFactory = loggerFactory + ?? throw new ArgumentNullException(nameof(loggerFactory)); + + public IConfigurationProvider Build(IConfigurationBuilder builder) + { + var logger = _loggerFactory.CreateLogger(); + return new SecureConfigProvider(_storageProvider, _cryptoProvider, logger); + } +} \ No newline at end of file diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/SecureConfigExtensions.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/SecureConfigExtensions.cs new file mode 100644 index 0000000..3221b9a --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/SecureConfigExtensions.cs @@ -0,0 +1,134 @@ +using System.Text.Json; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +using StevanFreeborn.Extensions.Configuration.Secure.Configuration; +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; +using StevanFreeborn.Extensions.Configuration.Secure.Storage; + +namespace StevanFreeborn.Extensions.Configuration.Secure; + +/// +/// Provides extension methods for configuring secure configuration in .NET applications. +/// +public static class SecureConfigExtensions +{ + private const string JsonSerializerOptionsKey = "SecureConfigJsonSerializerOptions"; + + /// + /// Adds secure configuration to the configuration builder. + /// + /// The configuration builder to add secure configuration to. + /// An action to configure the secure configuration builder. + /// The configuration builder with secure configuration added. + /// Thrown when or is null. + /// Thrown when a required provider is not configured. + public static IConfigurationBuilder AddSecureConfig( + this IConfigurationBuilder builder, + Action configure + ) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configure); +#else + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (configure is null) + { + throw new ArgumentNullException(nameof(configure)); + } +#endif + + var configBuilder = new SecureConfigBuilder(); + + configure.Invoke(configBuilder); + + if (configBuilder.StorageProvider is null) + { + throw new InvalidOperationException("A storage provider must be configured."); + } + + if (configBuilder.KeyProvider is null) + { + throw new InvalidOperationException("A key provider must be configured"); + } + + if (configBuilder.CryptoProviderFactory is null) + { + throw new InvalidOperationException("A crypto provider must be configured."); + } + + var cryptoProvider = configBuilder.CryptoProviderFactory.Invoke(configBuilder.KeyProvider); + + var source = new SecureConfigSource(configBuilder.StorageProvider, cryptoProvider, configBuilder.LoggerFactory); + return builder.Add(source); + } + + /// + /// Adds secure configuration services to the service collection. + /// + /// The service collection to add secure configuration services to. + /// An action to configure the secure configuration builder. + /// The service collection with secure configuration services added. + /// Thrown when or is null. + /// Thrown when a required provider is not configured. + public static IServiceCollection AddSecureConfig( + this IServiceCollection services, + Action configure + ) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configure); +#else + if (services is null) + { + throw new ArgumentNullException(nameof(services)); + } + + if (configure is null) + { + throw new ArgumentNullException(nameof(configure)); + } +#endif + + var configBuilder = new SecureConfigBuilder(); + + configure(configBuilder); + + if (configBuilder.StorageProvider is null) + { + throw new InvalidOperationException("A storage provider must be configured."); + } + + if (configBuilder.KeyProvider is null) + { + throw new InvalidOperationException("A key provider must be configured"); + } + + if (configBuilder.CryptoProviderFactory is null) + { + throw new InvalidOperationException("A crypto provider must be configured."); + } + + services.TryAddKeyedSingleton(JsonSerializerOptionsKey, configBuilder.SerializerOptions); + services.TryAddSingleton(configBuilder.StorageProvider); + services.TryAddSingleton(configBuilder.KeyProvider); + services.TryAddSingleton(configBuilder.CryptoProviderFactory.Invoke(configBuilder.KeyProvider)); + services.TryAddSingleton(sp => + { + var storageProvider = sp.GetRequiredService(); + var cryptoProvider = sp.GetRequiredService(); + var serializerOptions = sp.GetRequiredKeyedService(JsonSerializerOptionsKey); + return new SecureConfig(storageProvider, cryptoProvider, serializerOptions); + }); + + return services; + } +} \ No newline at end of file diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/StevanFreeborn.Extensions.Configuration.Secure.Tests.csproj b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/StevanFreeborn.Extensions.Configuration.Secure.Tests.csproj index 7f9cc0f..9474eab 100644 --- a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/StevanFreeborn.Extensions.Configuration.Secure.Tests.csproj +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/StevanFreeborn.Extensions.Configuration.Secure.Tests.csproj @@ -1,7 +1,7 @@  - net11.0 + net8.0;net10.0; enable enable false diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigBuilderTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigBuilderTests.cs new file mode 100644 index 0000000..8a05a77 --- /dev/null +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigBuilderTests.cs @@ -0,0 +1,331 @@ +using System.Security.Cryptography; +using System.Text.Json.Serialization.Metadata; + +using Microsoft.Extensions.Logging; + +using Microsoft.Extensions.Logging.Abstractions; + +using Moq; + +using StevanFreeborn.Extensions.Configuration.Secure.Configuration; +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; +using StevanFreeborn.Extensions.Configuration.Secure.Storage; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Configuration; +public class SecureConfigBuilderTests +{ + private readonly SecureConfigBuilder _sut = new(); + + [Fact] + public void Constructor_WhenCalled_ItShouldInitializeWithDefaultValues() + { + _sut.StorageProvider.Should().BeNull(); + _sut.CryptoProviderFactory.Should().BeNull(); + _sut.KeyProvider.Should().BeNull(); + _sut.LoggerFactory.Should().Be(NullLoggerFactory.Instance); + _sut.SerializerOptions.PropertyNameCaseInsensitive.Should().BeTrue(); + } + + [Fact] + public void UseJsonFileStorage_WithOptions_ItShouldSetStorageProvider() + { + var options = new JsonStorageOptions + { + FileName = "test_config.json", + DirectoryPath = "/tmp/config" + }; + + var result = _sut.UseJsonFileStorage(options); + + result.Should().BeSameAs(_sut); + _sut.StorageProvider.Should().NotBeNull(); + _sut.StorageProvider.Should().BeOfType(); + } + + [Fact] + public void UseJsonFileStorage_WithOptions_ItShouldUseProvidedOptions() + { + var options = new JsonStorageOptions + { + FileName = "custom.json", + DirectoryPath = "/custom/path" + }; + + _sut.UseJsonFileStorage(options); + + _sut.StorageProvider.Should().BeOfType(); + } + + [Fact] + public void UseJsonFileStorage_WithConfigureAction_ItShouldSetStorageProvider() + { + var result = _sut.UseJsonFileStorage(opt => opt.FileName = "test.json"); + + result.Should().BeSameAs(_sut); + _sut.StorageProvider.Should().NotBeNull(); + _sut.StorageProvider.Should().BeOfType(); + } + + [Fact] + public void UseJsonFileStorage_WithConfigureAction_ItShouldApplyConfiguration() + { + _sut.UseJsonFileStorage(opt => + { + opt.FileName = "configured.json"; + opt.DirectoryPath = "/configured/path"; + }); + + _sut.StorageProvider.Should().BeOfType(); + } + + [Fact] + public void UseJsonFileStorage_WithNullConfigureAction_ItShouldThrowArgumentNullException() + { + var act = () => _sut.UseJsonFileStorage((Action)null!); + + act.Should().Throw() + .WithParameterName("configure"); + } + + [Fact] + public void AddJsonAotContext_WithContext_ItShouldAddToResolverChain() + { + var mockContext = new Mock(); + + var result = _sut.AddJsonAotContext(mockContext.Object); + + result.Should().BeSameAs(_sut); + _sut.SerializerOptions.TypeInfoResolverChain.Should().Contain(mockContext.Object); + } + + [Fact] + public void AddJsonAotContext_WithNullContext_ItShouldThrowArgumentNullException() + { + var act = () => _sut.AddJsonAotContext(null!); + + act.Should().Throw() + .WithParameterName("context"); + } + + [Fact] + public void AddJsonAotContext_WhenCalledMultipleTimes_ItShouldAddAllToChain() + { + var mockContext1 = new Mock(); + var mockContext2 = new Mock(); + + _sut.AddJsonAotContext(mockContext1.Object); + _sut.AddJsonAotContext(mockContext2.Object); + + _sut.SerializerOptions.TypeInfoResolverChain.Should().Contain(mockContext1.Object); + _sut.SerializerOptions.TypeInfoResolverChain.Should().Contain(mockContext2.Object); + } + + [Fact] + public void UseCustomStorage_WithProvider_ItShouldSetStorageProvider() + { + var mockProvider = new Mock(); + + var result = _sut.UseCustomStorage(mockProvider.Object); + + result.Should().BeSameAs(_sut); + _sut.StorageProvider.Should().BeSameAs(mockProvider.Object); + } + + [Fact] + public void UseCustomStorage_WithNullProvider_ItShouldThrowArgumentNullException() + { + var act = () => _sut.UseCustomStorage(null!); + + act.Should().Throw() + .WithParameterName("provider"); + } + + [Fact] + public void WithBase64EncryptionKey_WithValidKey_ItShouldSetKeyProvider() + { + var validKey = Convert.ToBase64String(new byte[32]); + + var result = _sut.WithBase64EncryptionKey(validKey); + + result.Should().BeSameAs(_sut); + _sut.KeyProvider.Should().NotBeNull(); + _sut.KeyProvider.Should().BeOfType(); + } + + [Fact] + public void WithBase64EncryptionKey_WithValidKey_ItShouldReturnCorrectKey() + { + var keyBytes = new byte[32]; + RandomNumberGenerator.Fill(keyBytes); + + var validKey = Convert.ToBase64String(keyBytes); + + _sut.WithBase64EncryptionKey(validKey); + + _sut.KeyProvider!.GetKey().Should().Equal(keyBytes); + } + + [Fact] + public void WithMachineIdKey_WhenCalled_ItShouldSetKeyProvider() + { + var result = _sut.WithMachineIdKey(); + + result.Should().BeSameAs(_sut); + _sut.KeyProvider.Should().NotBeNull(); + _sut.KeyProvider.Should().BeOfType(); + } + + [Fact] + public void WithMachineIdKey_WhenCalled_ItShouldUseLoggerFactory() + { + var mockLoggerFactory = new Mock(); + var mockLogger = new Mock(); + + mockLoggerFactory.Setup(f => f.CreateLogger(typeof(MachineIdKeyGenerator).FullName!)) + .Returns(mockLogger.Object); + + _sut.WithLoggerFactory(mockLoggerFactory.Object); + + _sut.WithMachineIdKey(); + + mockLoggerFactory.Verify(f => f.CreateLogger(typeof(MachineIdKeyGenerator).FullName!), Times.Once()); + } + + [Fact] + public void WithCustomKeyProvider_WithProvider_ItShouldSetKeyProvider() + { + var mockProvider = new Mock(); + + var result = _sut.WithCustomKeyProvider(mockProvider.Object); + + result.Should().BeSameAs(_sut); + _sut.KeyProvider.Should().BeSameAs(mockProvider.Object); + } + + [Fact] + public void WithCustomKeyProvider_WithNullProvider_ItShouldThrowArgumentNullException() + { + var act = () => _sut.WithCustomKeyProvider(null!); + + act.Should().Throw() + .WithParameterName("provider"); + } + + [Fact] + public void WithLoggerFactory_WithFactory_ItShouldSetLoggerFactory() + { + var mockFactory = new Mock(); + + var result = _sut.WithLoggerFactory(mockFactory.Object); + + result.Should().BeSameAs(_sut); + _sut.LoggerFactory.Should().BeSameAs(mockFactory.Object); + } + + [Fact] + public void WithLoggerFactory_WithNullFactory_ItShouldSetNullLoggerFactory() + { + var mockFactory = new Mock(); + _sut.WithLoggerFactory(mockFactory.Object); + + _sut.WithLoggerFactory(null!); + + _sut.LoggerFactory.Should().Be(NullLoggerFactory.Instance); + } + + [Fact] + public void WithAesCryptoProvider_WhenCalled_ItShouldSetCryptoProviderFactory() + { + var mockKeyProvider = new Mock(); + + var result = _sut.WithAesCryptoProvider(); + + result.Should().BeSameAs(_sut); + _sut.CryptoProviderFactory.Should().NotBeNull(); + + var cryptoProvider = _sut.CryptoProviderFactory!(mockKeyProvider.Object); + cryptoProvider.Should().BeOfType(); + } + + [Fact] + public void WithCustomCryptoProvider_WithFactory_ItShouldSetCryptoProviderFactory() + { + var mockCryptoProvider = new Mock(); + var mockKeyProvider = new Mock(); + Func factory = (kp) => mockCryptoProvider.Object; + + var result = _sut.WithCustomCryptoProvider(factory); + + result.Should().BeSameAs(_sut); + _sut.CryptoProviderFactory.Should().NotBeNull(); + + var cryptoProvider = _sut.CryptoProviderFactory!(mockKeyProvider.Object); + cryptoProvider.Should().BeSameAs(mockCryptoProvider.Object); + } + + [Fact] + public void WithCustomCryptoProvider_WithNullFactory_ItShouldSetNull() + { + _sut.WithCustomCryptoProvider(null!); + + _sut.CryptoProviderFactory.Should().BeNull(); + } + + [Fact] + public void MethodChaining_WhenAllMethodsCalled_ItShouldConfigureAllProperties() + { + var mockStorageProvider = new Mock(); + var mockKeyProvider = new Mock(); + var mockLoggerFactory = new Mock(); + var mockCryptoProvider = new Mock(); + + _sut.UseCustomStorage(mockStorageProvider.Object) + .WithCustomKeyProvider(mockKeyProvider.Object) + .WithLoggerFactory(mockLoggerFactory.Object) + .WithCustomCryptoProvider((kp) => mockCryptoProvider.Object); + + _sut.StorageProvider.Should().BeSameAs(mockStorageProvider.Object); + _sut.KeyProvider.Should().BeSameAs(mockKeyProvider.Object); + _sut.LoggerFactory.Should().BeSameAs(mockLoggerFactory.Object); + _sut.CryptoProviderFactory.Should().NotBeNull(); + + var provider = _sut.CryptoProviderFactory!(mockKeyProvider.Object); + provider.Should().BeSameAs(mockCryptoProvider.Object); + } + + [Fact] + public void MethodChaining_WhenOverridingStorage_ItShouldUseLastSetProvider() + { + var mockProvider1 = new Mock(); + var mockProvider2 = new Mock(); + + _sut.UseCustomStorage(mockProvider1.Object) + .UseCustomStorage(mockProvider2.Object); + + _sut.StorageProvider.Should().BeSameAs(mockProvider2.Object); + } + + [Fact] + public void MethodChaining_WhenOverridingKeyProvider_ItShouldUseLastSetProvider() + { + var mockProvider1 = new Mock(); + var mockProvider2 = new Mock(); + + _sut.WithCustomKeyProvider(mockProvider1.Object) + .WithCustomKeyProvider(mockProvider2.Object); + + _sut.KeyProvider.Should().BeSameAs(mockProvider2.Object); + } + + [Fact] + public void MethodChaining_WhenOverridingLoggerFactory_ItShouldUseLastSetFactory() + { + var mockFactory1 = new Mock(); + var mockFactory2 = new Mock(); + + _sut.WithLoggerFactory(mockFactory1.Object) + .WithLoggerFactory(mockFactory2.Object); + + _sut.LoggerFactory.Should().BeSameAs(mockFactory2.Object); + } +} \ No newline at end of file diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigProviderTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigProviderTests.cs index cb3c152..d943da9 100644 --- a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigProviderTests.cs +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigProviderTests.cs @@ -65,7 +65,7 @@ public class SecureConfigProviderTests { "BadKey", "encrypted_bad" }, }; - _mockLogger.Setup(m => m.IsEnabled(LogLevel.Error)).Returns(true); + _mockLogger.Setup(m => m.IsEnabled(LogLevel.Warning)).Returns(true); _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(storedData); _mockCrypto.Setup(m => m.Decrypt("encrypted_valid")).Returns(@"{ ""Name"": ""test"" }"); _mockCrypto.Setup(m => m.Decrypt("encrypted_bad")).Throws(new InvalidOperationException("Decryption failed")); @@ -76,7 +76,7 @@ public class SecureConfigProviderTests val.Should().Be("test"); _mockLogger.Verify(logger => logger.Log( - LogLevel.Error, + LogLevel.Warning, It.Is(id => id.Id == 3), It.Is((state, type) => state.ToString()!.Contains("Failed to decrypt value for key")), It.IsAny(), @@ -495,14 +495,14 @@ public class SecureConfigProviderTests [Fact] public void Load_WhenCalledWithInvalidJson_ItShouldLogErrorAndContinue() { - _mockLogger.Setup(m => m.IsEnabled(LogLevel.Error)).Returns(true); + _mockLogger.Setup(m => m.IsEnabled(LogLevel.Warning)).Returns(true); _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Bad", "enc" } }); _mockCrypto.Setup(m => m.Decrypt("enc")).Returns("not valid json{{{"); _sut.Load(); _mockLogger.Verify(x => x.Log( - LogLevel.Error, + LogLevel.Warning, It.IsAny(), It.Is((v, _) => v.ToString()!.Contains("Bad")), It.IsAny(), @@ -511,4 +511,4 @@ public class SecureConfigProviderTests Times.Once() ); } -} +} \ No newline at end of file diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigSourceTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigSourceTests.cs new file mode 100644 index 0000000..7a29345 --- /dev/null +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigSourceTests.cs @@ -0,0 +1,140 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +using Moq; + +using StevanFreeborn.Extensions.Configuration.Secure.Configuration; +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; +using StevanFreeborn.Extensions.Configuration.Secure.Storage; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Configuration; + +public class SecureConfigSourceTests +{ + private readonly Mock _mockStorage = new(); + private readonly Mock _mockCrypto = new(); + private readonly Mock _mockLoggerFactory = new(); + + [Fact] + public void Constructor_WhenStorageProviderIsNull_ItShouldThrowArgumentNullException() + { + var act = () => new SecureConfigSource( + storageProvider: null!, + cryptoProvider: _mockCrypto.Object, + loggerFactory: _mockLoggerFactory.Object + ); + + act.Should().Throw() + .WithParameterName("storageProvider"); + } + + [Fact] + public void Constructor_WhenCryptoProviderIsNull_ItShouldThrowArgumentNullException() + { + var act = () => new SecureConfigSource( + storageProvider: _mockStorage.Object, + cryptoProvider: null!, + loggerFactory: _mockLoggerFactory.Object + ); + + act.Should().Throw() + .WithParameterName("cryptoProvider"); + } + + [Fact] + public void Constructor_WhenLoggerFactoryIsNull_ItShouldThrowArgumentNullException() + { + var act = () => new SecureConfigSource( + storageProvider: _mockStorage.Object, + cryptoProvider: _mockCrypto.Object, + loggerFactory: null! + ); + + act.Should().Throw() + .WithParameterName("loggerFactory"); + } + + [Fact] + public void Constructor_WhenAllDependenciesAreProvided_ItShouldNotThrow() + { + var act = () => new SecureConfigSource( + storageProvider: _mockStorage.Object, + cryptoProvider: _mockCrypto.Object, + loggerFactory: _mockLoggerFactory.Object + ); + + act.Should().NotThrow(); + } + + [Fact] + public void Build_WhenCalled_ItShouldReturnSecureConfigProvider() + { + var sut = new SecureConfigSource( + storageProvider: _mockStorage.Object, + cryptoProvider: _mockCrypto.Object, + loggerFactory: _mockLoggerFactory.Object + ); + + var mockBuilder = new Mock(); + + var result = sut.Build(mockBuilder.Object); + + result.Should().NotBeNull(); + result.Should().BeOfType(); + } + + [Fact] + public void Build_WhenCalled_ItShouldCreateLoggerFromFactory() + { + var mockLogger = new Mock>(); + _mockLoggerFactory + .Setup(f => f.CreateLogger(typeof(SecureConfigProvider).FullName!)) + .Returns(mockLogger.Object); + + var sut = new SecureConfigSource( + storageProvider: _mockStorage.Object, + cryptoProvider: _mockCrypto.Object, + loggerFactory: _mockLoggerFactory.Object + ); + + var mockBuilder = new Mock(); + sut.Build(mockBuilder.Object); + + _mockLoggerFactory.Verify( + f => f.CreateLogger(typeof(SecureConfigProvider).FullName!), + Times.Once() + ); + } + + [Fact] + public void Build_WhenCalledMultipleTimes_ItShouldReturnNewProviderInstance() + { + var sut = new SecureConfigSource( + storageProvider: _mockStorage.Object, + cryptoProvider: _mockCrypto.Object, + loggerFactory: _mockLoggerFactory.Object + ); + + var mockBuilder = new Mock(); + + var result1 = sut.Build(mockBuilder.Object); + var result2 = sut.Build(mockBuilder.Object); + + result1.Should().NotBeSameAs(result2); + } + + [Fact] + public void Build_WhenCalledWithNullBuilder_ItShouldStillReturnProvider() + { + var sut = new SecureConfigSource( + storageProvider: _mockStorage.Object, + cryptoProvider: _mockCrypto.Object, + loggerFactory: _mockLoggerFactory.Object + ); + + var result = sut.Build(builder: null!); + + result.Should().NotBeNull(); + result.Should().BeOfType(); + } +} \ No newline at end of file diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigTests.cs index 029d9ca..b684c6a 100644 --- a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigTests.cs +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigTests.cs @@ -1,28 +1,31 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; + using Moq; using StevanFreeborn.Extensions.Configuration.Secure.Configuration; using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; using StevanFreeborn.Extensions.Configuration.Secure.Storage; -using System.Text.Json; - namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Configuration; public class SecureConfigTests { private readonly Mock _mockCryptoProvider = new(); private readonly Mock _mockStorageProvider = new(); + private readonly JsonSerializerOptions _jsonSerializerOptions = new(); private readonly SecureConfig _sut; public SecureConfigTests() { - _sut = new(_mockStorageProvider.Object, _mockCryptoProvider.Object); + _sut = new(_mockStorageProvider.Object, _mockCryptoProvider.Object, _jsonSerializerOptions); } [Fact] public void Constructor_WhenCalledWithNullStorageProvider_ItShouldThrowArgumentNullException() { - var act = () => new SecureConfig(null!, _mockCryptoProvider.Object); + var act = () => new SecureConfig(null!, _mockCryptoProvider.Object, _jsonSerializerOptions); act.Should().Throw(); } @@ -30,7 +33,15 @@ public class SecureConfigTests [Fact] public void Constructor_WhenCalledWithNullCryptoProvider_ItShouldThrowArgumentNullException() { - var act = () => new SecureConfig(_mockStorageProvider.Object, null!); + var act = () => new SecureConfig(_mockStorageProvider.Object, null!, _jsonSerializerOptions); + + act.Should().Throw(); + } + + [Fact] + public void Constructor_WhenCalledWithNullJsonOptions_ItShouldThrowArgumentNullException() + { + var act = () => new SecureConfig(_mockStorageProvider.Object, _mockCryptoProvider.Object, null!); act.Should().Throw(); } @@ -38,22 +49,22 @@ public class SecureConfigTests [Fact] public async Task SetAsync_WhenCalledWithNullKey_ItShouldThrowArgumentNullException() { - var act = async () => await _sut.SetAsync(null!, string.Empty); + var act = async () => await _sut.SetAsync(null!, string.Empty, SecureConfigTestsJsonContext.Default.String); - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync(); } [Fact] public async Task SetAsync_WhenCalledWithNullValue_ItShouldThrowArgumentNullException() { - var act = async () => await _sut.SetAsync("Key", null!); + var act = async () => await _sut.SetAsync("Key", null!, SecureConfigTestsJsonContext.Default.String); await act.Should().ThrowAsync(); } [Fact] - public async Task SetAsync_WhenCalled_ItShouldSerializeGivenValueAndEncryptIt() + public async Task SetAsync_WhenCalledWithoutJsonContextSet_ItShouldSerializeGivenValueAndEncryptIt() { var key = "Database"; var config = new DummyConfig("localhost", 9999); @@ -62,17 +73,81 @@ public class SecureConfigTests _mockCryptoProvider.Setup(m => m.Encrypt(json)).Returns(encryptedString); + var act = async () => await _sut.SetAsync(key, config); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task SetAsync_WhenCalledWithJsonContextSet_ItShouldSerializeGivenValueAndEncryptIt() + { + var key = "Database"; + var config = new DummyConfig("localhost", 9999); + var encryptedString = "encryptedString"; + var json = JsonSerializer.Serialize(config); + + _jsonSerializerOptions.TypeInfoResolverChain.Insert(0, SecureConfigTestsJsonContext.Default); + _mockCryptoProvider.Setup(m => m.Encrypt(json)).Returns(encryptedString); + await _sut.SetAsync(key, config); - _mockStorageProvider.Verify(m => m.WriteAsync(key, encryptedString), Times.Once()); + _mockStorageProvider.Verify(m => m.WriteAsync(key, encryptedString, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task SetAsync_WhenCalledWithTypeInfo_ItShouldSerializeGivenValueAndEncryptIt() + { + var key = "Database"; + var config = new DummyConfig("localhost", 9999); + var encryptedString = "encryptedString"; + var json = JsonSerializer.Serialize(config); + + _mockCryptoProvider.Setup(m => m.Encrypt(json)).Returns(encryptedString); + + await _sut.SetAsync(key, config, SecureConfigTestsJsonContext.Default.DummyConfig); + + _mockStorageProvider.Verify(m => m.WriteAsync(key, encryptedString, It.IsAny()), Times.Once()); } [Fact] public async Task GetAsync_WhenCalledWithNullKey_ItShouldThrowArgumentNullException() { - var act = async () => await _sut.GetAsync(null!); + var act = async () => await _sut.GetAsync(null!, SecureConfigTestsJsonContext.Default.DummyConfig); - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetAsync_WhenKeyExistsAndJsonContextNotSet_ItShouldReadDecryptAndDeserializeTheValue() + { + var key = "Database"; + var expectedConfig = new DummyConfig("localhost", 9999); + var encryptedString = "encryptedString"; + var json = JsonSerializer.Serialize(expectedConfig); + + _mockStorageProvider.Setup(m => m.ReadAsync(key, It.IsAny())).ReturnsAsync(encryptedString); + _mockCryptoProvider.Setup(m => m.Decrypt(encryptedString)).Returns(json); + + var act = async () => await _sut.GetAsync(key); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetAsync_WhenKeyExistsAndJsonContextIsSet_ItShouldReadDecryptAndDeserializeTheValue() + { + var key = "Database"; + var expectedConfig = new DummyConfig("localhost", 9999); + var encryptedString = "encryptedString"; + var json = JsonSerializer.Serialize(expectedConfig); + + _jsonSerializerOptions.TypeInfoResolverChain.Insert(0, SecureConfigTestsJsonContext.Default); + _mockStorageProvider.Setup(m => m.ReadAsync(key, It.IsAny())).ReturnsAsync(encryptedString); + _mockCryptoProvider.Setup(m => m.Decrypt(encryptedString)).Returns(json); + + var result = await _sut.GetAsync(key, SecureConfigTestsJsonContext.Default.DummyConfig); + + result.Should().BeEquivalentTo(expectedConfig); } [Fact] @@ -83,10 +158,10 @@ public class SecureConfigTests var encryptedString = "encryptedString"; var json = JsonSerializer.Serialize(expectedConfig); - _mockStorageProvider.Setup(m => m.ReadAsync(key)).ReturnsAsync(encryptedString); + _mockStorageProvider.Setup(m => m.ReadAsync(key, It.IsAny())).ReturnsAsync(encryptedString); _mockCryptoProvider.Setup(m => m.Decrypt(encryptedString)).Returns(json); - var result = await _sut.GetAsync(key); + var result = await _sut.GetAsync(key, SecureConfigTestsJsonContext.Default.DummyConfig); result.Should().BeEquivalentTo(expectedConfig); } @@ -98,9 +173,9 @@ public class SecureConfigTests var expectedConfig = new DummyConfig("localhost", 9999); var json = JsonSerializer.Serialize(expectedConfig); - _mockStorageProvider.Setup(m => m.ReadAsync(key)).ReturnsAsync(string.Empty); + _mockStorageProvider.Setup(m => m.ReadAsync(key, It.IsAny())).ReturnsAsync(string.Empty); - var result = await _sut.GetAsync(key); + var result = await _sut.GetAsync(key, SecureConfigTestsJsonContext.Default.DummyConfig); result.Should().BeNull(); } @@ -110,13 +185,19 @@ public class SecureConfigTests { var key = "Database"; - _mockStorageProvider.Setup(m => m.DeleteAsync(key)).ReturnsAsync(true); + _mockStorageProvider.Setup(m => m.DeleteAsync(key, It.IsAny())).ReturnsAsync(true); var result = await _sut.DeleteAsync(key); result.Should().BeTrue(); - _mockStorageProvider.Verify(m => m.DeleteAsync(key), Times.Once()); + _mockStorageProvider.Verify(m => m.DeleteAsync(key, It.IsAny()), Times.Once()); } +} - private sealed record DummyConfig(string Host, int Port); +internal sealed record DummyConfig(string Host, int Port); + +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(DummyConfig))] +internal partial class SecureConfigTestsJsonContext : JsonSerializerContext +{ } \ No newline at end of file diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/SecureConfigExtensionsTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/SecureConfigExtensionsTests.cs new file mode 100644 index 0000000..592af45 --- /dev/null +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/SecureConfigExtensionsTests.cs @@ -0,0 +1,748 @@ +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +using Moq; + +using StevanFreeborn.Extensions.Configuration.Secure.Configuration; +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; +using StevanFreeborn.Extensions.Configuration.Secure.Storage; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit; + +public class SecureConfigExtensionsTests +{ + private readonly Mock _mockStorageProvider = new(); + private readonly Mock _mockKeyProvider = new(); + private readonly Mock _mockCryptoProvider = new(); + private readonly Mock _mockLoggerFactory = new(); + + [Fact] + public void AddSecureConfig_WithNullBuilder_ItShouldThrowArgumentNullException() + { + IConfigurationBuilder builder = null!; + + var act = () => builder.AddSecureConfig(config => { }); + + act.Should().Throw() + .WithParameterName("builder"); + } + + [Fact] + public void AddSecureConfig_WithNullConfigure_ItShouldThrowArgumentNullException() + { + var builder = new ConfigurationBuilder(); + + var act = () => builder.AddSecureConfig(null!); + + act.Should().Throw() + .WithParameterName("configure"); + } + + [Fact] + public void AddSecureConfig_WhenStorageProviderNotConfigured_ItShouldThrowInvalidOperationException() + { + var builder = new ConfigurationBuilder(); + + var act = () => builder.AddSecureConfig(config => + { + config.WithBase64EncryptionKey(GetValidBase64Key()); + config.WithAesCryptoProvider(); + }); + + act.Should().Throw() + .WithMessage("A storage provider must be configured."); + } + + [Fact] + public void AddSecureConfig_WhenKeyProviderNotConfigured_ItShouldThrowInvalidOperationException() + { + var builder = new ConfigurationBuilder(); + + var act = () => builder.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithAesCryptoProvider(); + }); + + act.Should().Throw() + .WithMessage("A key provider must be configured"); + } + + [Fact] + public void AddSecureConfig_WhenCryptoProviderNotConfigured_ItShouldThrowInvalidOperationException() + { + var builder = new ConfigurationBuilder(); + + var act = () => builder.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithBase64EncryptionKey(GetValidBase64Key()); + }); + + act.Should().Throw() + .WithMessage("A crypto provider must be configured."); + } + + [Fact] + public void AddSecureConfig_WhenProperlyConfigured_ItShouldAddSecureConfigSource() + { + var builder = new ConfigurationBuilder(); + + var result = builder.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithBase64EncryptionKey(GetValidBase64Key()); + config.WithAesCryptoProvider(); + }); + + result.Should().BeSameAs(builder); + builder.Sources.Should().ContainSingle(s => s is SecureConfigSource); + } + + [Fact] + public void AddSecureConfig_WhenProperlyConfigured_ItShouldBuildConfigurationProvider() + { + var encryptedValue = "encrypted_value"; + var decryptedJson = @"{ ""Setting"": ""Value"", ""Number"": 42 }"; + + _mockStorageProvider + .Setup(s => s.ReadAllAsync(It.IsAny())) + .ReturnsAsync(new Dictionary { { "MySection", encryptedValue } }); + + _mockCryptoProvider + .Setup(c => c.Decrypt(encryptedValue)) + .Returns(decryptedJson); + + var configuration = new ConfigurationBuilder() + .AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }) + .Build(); + + configuration.GetSection("MySection")["Setting"].Should().Be("Value"); + configuration.GetSection("MySection")["Number"].Should().Be("42"); + } + + [Fact] + public void AddSecureConfig_WithJsonFileStorage_ItShouldWorkEndToEnd() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + var filePath = Path.Combine(tempDir, "test_config.json"); + + try + { + Directory.CreateDirectory(tempDir); + + var keyBytes = new byte[32]; + RandomNumberGenerator.Fill(keyBytes); + var keyProvider = new StaticKeyProvider(Convert.ToBase64String(keyBytes)); + var cryptoProvider = new AesCryptoProvider(keyProvider); + + var originalData = @"{ ""AppName"": ""TestApp"", ""Version"": ""1.0.0"" }"; + var encryptedData = cryptoProvider.Encrypt(originalData); + + File.WriteAllText(filePath, $"{{\"Settings\":\"{encryptedData}\"}}"); + + var configuration = new ConfigurationBuilder() + .AddSecureConfig(config => + { + config.UseJsonFileStorage(options => + { + options.DirectoryPath = tempDir; + options.FileName = "test_config.json"; + }); + config.WithBase64EncryptionKey(Convert.ToBase64String(keyBytes)); + config.WithAesCryptoProvider(); + }) + .Build(); + + configuration["Settings:AppName"].Should().Be("TestApp"); + configuration["Settings:Version"].Should().Be("1.0.0"); + } + finally + { + if (File.Exists(filePath)) File.Delete(filePath); + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } + + [Fact] + public void AddSecureConfig_WithLoggerFactory_ItShouldUseProvidedLoggerFactory() + { + _mockStorageProvider + .Setup(s => s.ReadAllAsync(It.IsAny())) + .ReturnsAsync(new Dictionary()); + + var mockLogger = new Mock>(); + + _mockLoggerFactory + .Setup(f => f.CreateLogger(typeof(SecureConfigProvider).FullName!)) + .Returns(mockLogger.Object); + + var builder = new ConfigurationBuilder(); + + builder.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithBase64EncryptionKey(GetValidBase64Key()); + config.WithAesCryptoProvider(); + config.WithLoggerFactory(_mockLoggerFactory.Object); + }) + .Build(); + + _mockLoggerFactory.Verify( + f => f.CreateLogger(typeof(SecureConfigProvider).FullName!), + Times.Once() + ); + } + + [Fact] + public void AddSecureConfig_WithMultipleCalls_ItShouldAddMultipleSources() + { + var builder = new ConfigurationBuilder(); + + builder.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithBase64EncryptionKey(GetValidBase64Key()); + config.WithAesCryptoProvider(); + }); + + builder.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithBase64EncryptionKey(GetValidBase64Key()); + config.WithAesCryptoProvider(); + }); + + builder.Sources.Should().HaveCount(2); + builder.Sources.Should().AllBeOfType(); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_WithNullServices_ItShouldThrowArgumentNullException() + { + IServiceCollection services = null!; + + var act = () => services.AddSecureConfig(config => { }); + + act.Should().Throw() + .WithParameterName("services"); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_WithNullConfigure_ItShouldThrowArgumentNullException() + { + var services = new ServiceCollection(); + + var act = () => services.AddSecureConfig(null!); + + act.Should().Throw() + .WithParameterName("configure"); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_WhenStorageProviderNotConfigured_ItShouldThrowInvalidOperationException() + { + var services = new ServiceCollection(); + + var act = () => services.AddSecureConfig(config => + { + config.WithBase64EncryptionKey(GetValidBase64Key()); + config.WithAesCryptoProvider(); + }); + + act.Should().Throw() + .WithMessage("A storage provider must be configured."); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_WhenKeyProviderNotConfigured_ItShouldThrowInvalidOperationException() + { + var services = new ServiceCollection(); + + var act = () => services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithAesCryptoProvider(); + }); + + act.Should().Throw() + .WithMessage("A key provider must be configured"); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_WhenCryptoProviderNotConfigured_ItShouldThrowInvalidOperationException() + { + var services = new ServiceCollection(); + + var act = () => services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithBase64EncryptionKey(GetValidBase64Key()); + }); + + act.Should().Throw() + .WithMessage("A crypto provider must be configured."); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_WhenProperlyConfigured_ItShouldRegisterServices() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + services.Should().Contain(s => s.ServiceType == typeof(ISecureStorageProvider)); + services.Should().Contain(s => s.ServiceType == typeof(IEncryptionKeyProvider)); + services.Should().Contain(s => s.ServiceType == typeof(ICryptoProvider)); + services.Should().Contain(s => s.ServiceType == typeof(ISecureConfig)); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldReturnServiceCollection() + { + var services = new ServiceCollection(); + + var result = services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + result.Should().BeSameAs(services); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldRegisterStorageProviderAsSingleton() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var storageDescriptor = services.First(s => s.ServiceType == typeof(ISecureStorageProvider)); + storageDescriptor.Lifetime.Should().Be(ServiceLifetime.Singleton); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldRegisterKeyProviderAsSingleton() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var keyDescriptor = services.First(s => s.ServiceType == typeof(IEncryptionKeyProvider)); + keyDescriptor.Lifetime.Should().Be(ServiceLifetime.Singleton); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldRegisterCryptoProviderAsSingleton() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var cryptoDescriptor = services.First(s => s.ServiceType == typeof(ICryptoProvider)); + cryptoDescriptor.Lifetime.Should().Be(ServiceLifetime.Singleton); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldRegisterSecureConfigAsSingleton() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var secureConfigDescriptor = services.First(s => s.ServiceType == typeof(ISecureConfig)); + secureConfigDescriptor.Lifetime.Should().Be(ServiceLifetime.Singleton); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldResolveSecureConfigFromDI() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var serviceProvider = services.BuildServiceProvider(); + var secureConfig = serviceProvider.GetService(); + + secureConfig.Should().NotBeNull(); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldResolveSameSecureConfigInstance() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var serviceProvider = services.BuildServiceProvider(); + var instance1 = serviceProvider.GetRequiredService(); + var instance2 = serviceProvider.GetRequiredService(); + + instance1.Should().BeSameAs(instance2); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldResolveSameStorageProviderInstance() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var serviceProvider = services.BuildServiceProvider(); + var instance1 = serviceProvider.GetRequiredService(); + var instance2 = serviceProvider.GetRequiredService(); + + instance1.Should().BeSameAs(_mockStorageProvider.Object); + instance2.Should().BeSameAs(_mockStorageProvider.Object); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldResolveSameKeyProviderInstance() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var serviceProvider = services.BuildServiceProvider(); + var instance1 = serviceProvider.GetRequiredService(); + var instance2 = serviceProvider.GetRequiredService(); + + instance1.Should().BeSameAs(_mockKeyProvider.Object); + instance2.Should().BeSameAs(_mockKeyProvider.Object); + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldResolveSameCryptoProviderInstance() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var serviceProvider = services.BuildServiceProvider(); + var instance1 = serviceProvider.GetRequiredService(); + var instance2 = serviceProvider.GetRequiredService(); + + instance1.Should().BeSameAs(_mockCryptoProvider.Object); + instance2.Should().BeSameAs(_mockCryptoProvider.Object); + } + + [Fact] + public async Task AddSecureConfig_ServiceCollection_WithRealAesCryptoProvider_ItShouldWorkEndToEnd() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + var filePath = Path.Combine(tempDir, "test_config.json"); + + try + { + Directory.CreateDirectory(tempDir); + + var keyBytes = new byte[32]; + RandomNumberGenerator.Fill(keyBytes); + var base64Key = Convert.ToBase64String(keyBytes); + + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.AddJsonAotContext(SecureConfigExtensionsTestsJsonContext.Default); + config.UseJsonFileStorage(options => + { + options.DirectoryPath = tempDir; + options.FileName = "test_config.json"; + }); + config.WithBase64EncryptionKey(base64Key); + config.WithAesCryptoProvider(); + }); + + var serviceProvider = services.BuildServiceProvider(); + var secureConfig = serviceProvider.GetRequiredService(); + var storageProvider = serviceProvider.GetRequiredService(); + + var testObject = new TestConfig { Name = "TestName", Value = 123 }; + await secureConfig.SetAsync("TestKey", testObject); + + var retrievedObject = await secureConfig.GetAsync("TestKey"); + + retrievedObject.Should().NotBeNull(); + retrievedObject!.Name.Should().Be("TestName"); + retrievedObject.Value.Should().Be(123); + + File.Exists(filePath).Should().BeTrue(); + } + finally + { + if (File.Exists(filePath)) File.Delete(filePath); + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } + + [Fact] + public async Task AddSecureConfig_ServiceCollection_WithMachineIdKey_ItShouldWorkEndToEnd() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + + try + { + Directory.CreateDirectory(tempDir); + + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.AddJsonAotContext(SecureConfigExtensionsTestsJsonContext.Default); + config.UseJsonFileStorage(options => + { + options.DirectoryPath = tempDir; + options.FileName = "test_config.json"; + }); + config.WithMachineIdKey(); + config.WithAesCryptoProvider(); + }); + + var serviceProvider = services.BuildServiceProvider(); + var secureConfig = serviceProvider.GetRequiredService(); + + var testObject = new TestConfig { Name = "MachineIdTest", Value = 456 }; + await secureConfig.SetAsync("MachineTest", testObject); + + var retrievedObject = await secureConfig.GetAsync("MachineTest"); + + retrievedObject.Should().NotBeNull(); + retrievedObject!.Name.Should().Be("MachineIdTest"); + retrievedObject.Value.Should().Be(456); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } + + [Fact] + public void AddSecureConfig_ServiceCollection_ItShouldRegisterJsonSerializerOptions() + { + var services = new ServiceCollection(); + + services.AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }); + + var serviceProvider = services.BuildServiceProvider(); + var keyedService = serviceProvider.GetKeyedService("SecureConfigJsonSerializerOptions"); + + keyedService.Should().NotBeNull(); + keyedService!.PropertyNameCaseInsensitive.Should().BeTrue(); + } + + [Fact] + public void AddSecureConfig_CombinedWithOtherProviders_ItShouldMergeConfiguration() + { + var encryptedValue = "encrypted_value"; + var decryptedJson = @"{ ""SecureSetting"": ""SecureValue"" }"; + + _mockStorageProvider + .Setup(s => s.ReadAllAsync(It.IsAny())) + .ReturnsAsync(new Dictionary { { "SecureSection", encryptedValue } }); + + _mockCryptoProvider + .Setup(c => c.Decrypt(encryptedValue)) + .Returns(decryptedJson); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["RegularSetting"] = "RegularValue", + ["AnotherSetting"] = "AnotherValue" + }) + .AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }) + .Build(); + + configuration["RegularSetting"].Should().Be("RegularValue"); + configuration["AnotherSetting"].Should().Be("AnotherValue"); + configuration["SecureSection:SecureSetting"].Should().Be("SecureValue"); + } + + [Fact] + public void AddSecureConfig_WithNestedConfiguration_ItShouldFlattenCorrectly() + { + var encryptedValue = "encrypted_nested"; + var decryptedJson = @"{ + ""Level1"": { + ""Level2"": { + ""Setting"": ""NestedValue"" + } + } + }"; + + _mockStorageProvider + .Setup(s => s.ReadAllAsync(It.IsAny())) + .ReturnsAsync(new Dictionary { { "Nested", encryptedValue } }); + + _mockCryptoProvider + .Setup(c => c.Decrypt(encryptedValue)) + .Returns(decryptedJson); + + var configuration = new ConfigurationBuilder() + .AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }) + .Build(); + + configuration["Nested:Level1:Level2:Setting"].Should().Be("NestedValue"); + } + + [Fact] + public void AddSecureConfig_WithArrays_ItShouldIndexCorrectly() + { + var encryptedValue = "encrypted_array"; + var decryptedJson = @"{ ""Items"": [""First"", ""Second"", ""Third""] }"; + + _mockStorageProvider + .Setup(s => s.ReadAllAsync(It.IsAny())) + .ReturnsAsync(new Dictionary { { "ArraySection", encryptedValue } }); + + _mockCryptoProvider + .Setup(c => c.Decrypt(encryptedValue)) + .Returns(decryptedJson); + + var configuration = new ConfigurationBuilder() + .AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }) + .Build(); + + configuration["ArraySection:Items:0"].Should().Be("First"); + configuration["ArraySection:Items:1"].Should().Be("Second"); + configuration["ArraySection:Items:2"].Should().Be("Third"); + } + + [Fact] + public void AddSecureConfig_WithMultipleSections_ItShouldLoadAllSections() + { + var encrypted1 = "encrypted1"; + var encrypted2 = "encrypted2"; + var decrypted1 = @"{ ""Setting1"": ""Value1"" }"; + var decrypted2 = @"{ ""Setting2"": ""Value2"" }"; + + _mockStorageProvider + .Setup(s => s.ReadAllAsync(It.IsAny())) + .ReturnsAsync(new Dictionary + { + { "Section1", encrypted1 }, + { "Section2", encrypted2 } + }); + + _mockCryptoProvider + .Setup(c => c.Decrypt("encrypted1")) + .Returns(decrypted1); + + _mockCryptoProvider + .Setup(c => c.Decrypt("encrypted2")) + .Returns(decrypted2); + + var configuration = new ConfigurationBuilder() + .AddSecureConfig(config => + { + config.UseCustomStorage(_mockStorageProvider.Object); + config.WithCustomKeyProvider(_mockKeyProvider.Object); + config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object); + }) + .Build(); + + configuration["Section1:Setting1"].Should().Be("Value1"); + configuration["Section2:Setting2"].Should().Be("Value2"); + } + + private static string GetValidBase64Key() + { + var keyBytes = new byte[32]; + RandomNumberGenerator.Fill(keyBytes); + return Convert.ToBase64String(keyBytes); + } +} + +internal sealed class TestConfig +{ + public string Name { get; set; } = string.Empty; + public int Value { get; set; } +} + +[JsonSerializable(typeof(TestConfig))] +internal partial class SecureConfigExtensionsTestsJsonContext : JsonSerializerContext +{ +} \ No newline at end of file