diff --git a/src/Vault/Configuration/VaultConfigurationExtensions.cs b/src/Vault/Configuration/VaultConfigurationExtensions.cs index a7c3070..ff70c86 100644 --- a/src/Vault/Configuration/VaultConfigurationExtensions.cs +++ b/src/Vault/Configuration/VaultConfigurationExtensions.cs @@ -11,16 +11,19 @@ namespace Vault.Configuration; public static class VaultConfigurationExtensions { /// - /// Adds HashiCorp Vault as a configuration source. - /// Note: AddVault() must be called BEFORE this method to register VaultService. + /// Adds HashiCorp Vault as a configuration source with an existing VaultService. + /// Secrets are loaded immediately during Build() so they are available for + /// subsequent configuration such as Entity Framework connection strings. /// /// The configuration builder. /// The Vault environment to load (e.g., DEV, PROD). + /// VaultService instance to use. /// Optional action to configure the source. /// The configuration builder for chaining. public static IConfigurationBuilder AddVaultConfiguration( this IConfigurationBuilder builder, string environment, + IVaultService vaultService, Action? configureSource = null) { if (builder == null) @@ -35,29 +38,39 @@ public static IConfigurationBuilder AddVaultConfiguration( nameof(environment)); } + if (vaultService == null) + { + throw new ArgumentNullException(nameof(vaultService)); + } + var source = new VaultConfigurationSource { - Environment = environment + Environment = environment, + VaultService = vaultService }; configureSource?.Invoke(source); + // The source will create the provider and load secrets immediately in Build() return builder.Add(source); } /// - /// Adds HashiCorp Vault as a configuration source with an existing VaultService. - /// Useful for tests or when VaultService is created manually. + /// Adds HashiCorp Vault as a configuration source with an existing VaultService and logger. + /// Secrets are loaded immediately during Build() so they are available for + /// subsequent configuration such as Entity Framework connection strings. /// /// The configuration builder. /// The Vault environment to load (e.g., DEV, PROD). /// VaultService instance to use. + /// Optional logger for the configuration provider. /// Optional action to configure the source. /// The configuration builder for chaining. public static IConfigurationBuilder AddVaultConfiguration( this IConfigurationBuilder builder, string environment, IVaultService vaultService, + ILogger? logger, Action? configureSource = null) { if (builder == null) @@ -79,79 +92,14 @@ public static IConfigurationBuilder AddVaultConfiguration( var source = new VaultConfigurationSource { - Environment = environment + Environment = environment, + VaultService = vaultService, + Logger = logger }; configureSource?.Invoke(source); - var provider = new VaultConfigurationProvider(source, vaultService); - builder.Add(new VaultConfigurationSourceWrapper(source, provider)); - - return builder; - } - - /// - /// Inject VaultService into all existing VaultConfigurationProvider instances. - /// To be called after IConfiguration is built to initialize the providers. - /// - /// The built configuration. - /// The service provider containing VaultService. - public static void InitializeVaultProviders( - this IConfiguration configuration, - IServiceProvider serviceProvider) - { - if (configuration == null) - { - throw new ArgumentNullException(nameof(configuration)); - } - - if (serviceProvider == null) - { - throw new ArgumentNullException(nameof(serviceProvider)); - } - - if (configuration is not IConfigurationRoot configurationRoot) - { - return; - } - - var vaultService = serviceProvider.GetService(); - if (vaultService == null) - { - return; - } - - var logger = serviceProvider.GetService>(); - - foreach (var provider in configurationRoot.Providers) - { - if (provider is VaultConfigurationProvider vaultProvider) - { - vaultProvider.SetVaultService(vaultService, logger); - vaultProvider.Load(); - } - } - } - - /// - /// Wrapper to allow manual provider injection. - /// - private class VaultConfigurationSourceWrapper : IConfigurationSource - { - private readonly VaultConfigurationSource _source; - private readonly VaultConfigurationProvider _provider; - - public VaultConfigurationSourceWrapper( - VaultConfigurationSource source, - VaultConfigurationProvider provider) - { - _source = source; - _provider = provider; - } - - public IConfigurationProvider Build(IConfigurationBuilder builder) - { - return _provider; - } + // The source will create the provider and load secrets immediately in Build() + return builder.Add(source); } } diff --git a/src/Vault/Configuration/VaultConfigurationProvider.cs b/src/Vault/Configuration/VaultConfigurationProvider.cs index 2391171..405bfc4 100644 --- a/src/Vault/Configuration/VaultConfigurationProvider.cs +++ b/src/Vault/Configuration/VaultConfigurationProvider.cs @@ -13,15 +13,17 @@ public class VaultConfigurationProvider : ConfigurationProvider, IDisposable { private readonly VaultConfigurationSource _source; - private readonly IVaultService? _vaultService; + private readonly IVaultService _vaultService; private readonly ILogger? _logger; private Timer? _reloadTimer; private bool _disposed; /// /// Initializes a new instance of the class. - /// Main constructor used when VaultService is already available. /// + /// The configuration source. + /// The Vault service instance. + /// Optional logger for the provider. public VaultConfigurationProvider( VaultConfigurationSource source, IVaultService vaultService, @@ -38,22 +40,6 @@ public VaultConfigurationProvider( } } - /// - /// Initializes a new instance of the class. - /// Constructor for compatibility with IConfigurationSource.Build - /// VaultService will be injected later via SetVaultService. - /// - internal VaultConfigurationProvider(VaultConfigurationSource source) - { - _source = source ?? throw new ArgumentNullException(nameof(source)); - - if (string.IsNullOrWhiteSpace(_source.Environment)) - { - throw new InvalidOperationException( - "Vault environment must be specified (e.g., DEV, PROD)"); - } - } - /// /// Load secrets from Vault. /// @@ -79,28 +65,6 @@ public void Dispose() GC.SuppressFinalize(this); } - /// - /// Inject VaultService after creation (used by extension method). - /// - internal void SetVaultService(IVaultService vaultService, ILogger? logger = null) - { - if (_vaultService != null) - { - throw new InvalidOperationException("VaultService already set"); - } - - // Use reflection to assign the readonly field - var field = typeof(VaultConfigurationProvider).GetField( - "_vaultService", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - field?.SetValue(this, vaultService); - - var loggerField = typeof(VaultConfigurationProvider).GetField( - "_logger", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - loggerField?.SetValue(this, logger); - } - /// /// Convert an object value to string. /// @@ -120,35 +84,60 @@ internal void SetVaultService(IVaultService vaultService, ILogger - /// Check if a value is JSON. + /// Check if a value is JSON (object or array). + /// Handles both string values and JsonElement values. /// - private static bool IsJsonValue(object? value) + private static bool IsJsonValue(object? value, out string? jsonString) { - if (value is not string str) + jsonString = null; + + if (value == null) { return false; } - str = str.Trim(); - return (str.StartsWith("{") && str.EndsWith("}")) || (str.StartsWith("[") && str.EndsWith("]")); - } - - private async Task LoadAsync() - { - if (_vaultService == null) + // Handle JsonElement from VaultSharp + if (value is JsonElement jsonElement) { - var errorMessage = "VaultService is not initialized. " + - "Make sure to call AddVault() before AddVaultConfiguration()"; + if (jsonElement.ValueKind == JsonValueKind.Object || + jsonElement.ValueKind == JsonValueKind.Array) + { + jsonString = jsonElement.GetRawText(); + return true; + } - if (_source.Optional) + if (jsonElement.ValueKind == JsonValueKind.String) { - _logger?.LogWarning(errorMessage); - return; + var str = jsonElement.GetString()?.Trim(); + if (!string.IsNullOrEmpty(str) && + ((str.StartsWith("{") && str.EndsWith("}")) || + (str.StartsWith("[") && str.EndsWith("]")))) + { + jsonString = str; + return true; + } } - throw new InvalidOperationException(errorMessage); + return false; + } + + // Handle string values + if (value is string strValue) + { + var trimmed = strValue.Trim(); + if ((trimmed.StartsWith("{") && trimmed.EndsWith("}")) || + (trimmed.StartsWith("[") && trimmed.EndsWith("]"))) + { + jsonString = trimmed; + return true; + } } + return false; + } + + private async Task LoadAsync() + { try { _logger?.LogInformation( @@ -162,9 +151,9 @@ private async Task LoadAsync() foreach (var kvp in secrets) { // If the value is JSON, flatten it - if (IsJsonValue(kvp.Value)) + if (IsJsonValue(kvp.Value, out var jsonString)) { - FlattenJsonValue(kvp.Key, kvp.Value, data); + FlattenJsonValue(kvp.Key, jsonString, data); } else { @@ -229,11 +218,11 @@ private void LoadAndNotifyChange() } /// - /// Flatten a JSON value into dotted keys with dot notation. + /// Flatten a JSON value into configuration keys. /// - private void FlattenJsonValue(string parentKey, object? value, Dictionary data) + private void FlattenJsonValue(string parentKey, string? jsonString, Dictionary data) { - if (value is not string jsonString) + if (string.IsNullOrEmpty(jsonString)) { return; } diff --git a/src/Vault/Configuration/VaultConfigurationSource.cs b/src/Vault/Configuration/VaultConfigurationSource.cs index a2a85fd..56bc898 100644 --- a/src/Vault/Configuration/VaultConfigurationSource.cs +++ b/src/Vault/Configuration/VaultConfigurationSource.cs @@ -1,4 +1,6 @@ using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Vault.Abstractions; namespace Vault.Configuration; @@ -29,11 +31,34 @@ public class VaultConfigurationSource /// public int ReloadIntervalSeconds { get; set; } = 300; // 5 minutes by default + /// + /// Gets or sets the Vault service instance to use for loading secrets. + /// + internal IVaultService? VaultService { get; set; } + + /// + /// Gets or sets the logger for the configuration provider. + /// + internal ILogger? Logger { get; set; } + /// /// Build the configuration provider. + /// Secrets are loaded immediately to make them available for subsequent + /// configuration (e.g., connection strings for Entity Framework). /// + /// Thrown when VaultService is not set. public IConfigurationProvider Build(IConfigurationBuilder builder) { - return new VaultConfigurationProvider(this); + if (VaultService == null) + { + throw new InvalidOperationException( + "VaultService must be set. Use AddVault() or AddVaultConfiguration() with a VaultService instance."); + } + + // Create provider with VaultService and load immediately + var provider = new VaultConfigurationProvider(this, VaultService, Logger); + provider.Load(); + + return provider; } } diff --git a/src/Vault/Extensions/VaultExtension.cs b/src/Vault/Extensions/VaultExtension.cs index a8a1fe2..2c7dbff 100644 --- a/src/Vault/Extensions/VaultExtension.cs +++ b/src/Vault/Extensions/VaultExtension.cs @@ -1,5 +1,4 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Vault.Abstractions; using Vault.Configuration; @@ -16,6 +15,8 @@ public static class VaultExtension { /// /// Fully configures Vault: adds configuration source and registers VaultService. + /// Secrets are loaded immediately during configuration build, making them available + /// for subsequent configuration such as Entity Framework connection strings. /// /// The service collection. /// The application configuration. @@ -50,34 +51,6 @@ public static IServiceCollection AddVault( throw new ArgumentException("Environment cannot be empty", nameof(environment)); } - configuration.AddVaultConfiguration(environment, configureSource); - services.AddVaultService(vaultOptions); - - return services; - } - - /// - /// Initialize Vault providers after the application is built. - /// - /// The web application. - /// The web application for chaining. - public static WebApplication UseVault(this WebApplication app) - { - if (app == null) - { - throw new ArgumentNullException(nameof(app)); - } - - app.Configuration.InitializeVaultProviders(app.Services); - - return app; - } - - /// - /// Register VaultService in the DI container with specified options. - /// - private static IServiceCollection AddVaultService(this IServiceCollection services, VaultOptions vaultOptions) - { // Register VaultOptions singleton services.AddSingleton(vaultOptions); @@ -90,8 +63,14 @@ private static IServiceCollection AddVaultService(this IServiceCollection servic // Validate VaultOptions configuration VaultOptionsValidator.Validate(vaultOptions); - // Register VaultService as singleton - services.AddSingleton(); + // Create VaultService immediately so secrets can be loaded during configuration build + var vaultService = new VaultService(vaultOptions); + + // Add Vault configuration with immediate loading + configuration.AddVaultConfiguration(environment, vaultService, configureSource); + + // Register the same VaultService instance in DI for later use + services.AddSingleton(vaultService); return services; } diff --git a/src/Vault/Services/VaultService.cs b/src/Vault/Services/VaultService.cs index aeee6a6..e78be3b 100644 --- a/src/Vault/Services/VaultService.cs +++ b/src/Vault/Services/VaultService.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Vault.Abstractions; using Vault.Helpers; using Vault.Options; @@ -22,13 +23,13 @@ public class VaultService /// Initializes a new instance of the class. /// /// The Vault configuration options. - /// The logger instance. + /// The logger instance. If null, a NullLogger is used. public VaultService( VaultOptions options, - ILogger logger) + ILogger? logger = null) { _options = options ?? throw new ArgumentNullException(nameof(options)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _logger = logger ?? NullLogger.Instance; if (!options.IsActivated) { diff --git a/test/Vault.Tests/Configuration/VaultConfigurationSourceTests.cs b/test/Vault.Tests/Configuration/VaultConfigurationSourceTests.cs index 942bd41..55b3d6c 100644 --- a/test/Vault.Tests/Configuration/VaultConfigurationSourceTests.cs +++ b/test/Vault.Tests/Configuration/VaultConfigurationSourceTests.cs @@ -2,6 +2,8 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Microsoft.Extensions.Configuration; +using NSubstitute; +using Vault.Abstractions; using Vault.Configuration; using Xunit; @@ -78,71 +80,101 @@ public void VaultConfigurationSource_CanSetReloadIntervalSeconds() } [Fact] - public void Build_WithEmptyEnvironment_ThrowsInvalidOperationException() + public void Build_WithoutVaultService_ThrowsInvalidOperationException() { // Arrange var source = new VaultConfigurationSource { - Environment = string.Empty, + Environment = "dev", }; var builder = new ConfigurationBuilder(); // Act & Assert InvalidOperationException exception = Assert.Throws(() => source.Build(builder)); - Assert.Contains("environment must be specified", exception.Message); + Assert.Contains("VaultService must be set", exception.Message); } [Fact] - public void Build_WithValidEnvironment_ReturnsProvider() + public void AddVaultConfiguration_WithEmptyEnvironment_ThrowsArgumentException() { // Arrange - var source = new VaultConfigurationSource - { - Environment = "dev", - }; + var vaultService = Substitute.For(); + var builder = new ConfigurationBuilder(); + + // Act & Assert + ArgumentException exception = Assert.Throws(() => + builder.AddVaultConfiguration(string.Empty, vaultService)); + Assert.Equal("environment", exception.ParamName); + } + + [Fact] + public void AddVaultConfiguration_WithValidEnvironmentAndVaultService_ReturnsBuilder() + { + // Arrange + var vaultService = Substitute.For(); + vaultService.GetSecretsAsync("dev").Returns(new Dictionary()); var builder = new ConfigurationBuilder(); // Act - IConfigurationProvider provider = source.Build(builder); + IConfigurationBuilder result = builder.AddVaultConfiguration("dev", vaultService); // Assert - Assert.NotNull(provider); - Assert.IsType(provider); + Assert.Same(builder, result); } [Fact] - public void Build_WithWhitespaceEnvironment_ThrowsInvalidOperationException() + public void AddVaultConfiguration_WithWhitespaceEnvironment_ThrowsArgumentException() { // Arrange - var source = new VaultConfigurationSource - { - Environment = " ", - }; + var vaultService = Substitute.For(); var builder = new ConfigurationBuilder(); // Act & Assert - InvalidOperationException exception = Assert.Throws(() => source.Build(builder)); - Assert.Contains("environment must be specified", exception.Message); + ArgumentException exception = Assert.Throws(() => + builder.AddVaultConfiguration(" ", vaultService)); + Assert.Equal("environment", exception.ParamName); } [Fact] - public void Build_WithCustomReloadInterval_PreservesConfiguration() + public void AddVaultConfiguration_LoadsSecretsImmediately() { // Arrange - var source = new VaultConfigurationSource + var vaultService = Substitute.For(); + var secrets = new Dictionary { - Environment = "dev", - Optional = true, - ReloadOnChange = true, - ReloadIntervalSeconds = 600, + ["ConnectionStrings:Default"] = "Server=localhost;Database=Test", + ["AppSettings:ApiKey"] = "secret-key", }; + vaultService.GetSecretsAsync("dev").Returns(secrets); var builder = new ConfigurationBuilder(); // Act - IConfigurationProvider provider = source.Build(builder); + builder.AddVaultConfiguration("dev", vaultService); + IConfigurationRoot config = builder.Build(); // Assert - Assert.NotNull(provider); - Assert.IsType(provider); + Assert.Equal("Server=localhost;Database=Test", config["ConnectionStrings:Default"]); + Assert.Equal("secret-key", config["AppSettings:ApiKey"]); + } + + [Fact] + public void AddVaultConfiguration_WithConfigureSource_AppliesConfiguration() + { + // Arrange + var vaultService = Substitute.For(); + vaultService.GetSecretsAsync("prod").Returns(new Dictionary()); + var builder = new ConfigurationBuilder(); + + // Act + builder.AddVaultConfiguration("prod", vaultService, source => + { + source.Optional = true; + source.ReloadOnChange = true; + source.ReloadIntervalSeconds = 600; + }); + IConfigurationRoot config = builder.Build(); + + // Assert - configuration was built successfully + Assert.NotNull(config); } } diff --git a/test/Vault.Tests/Extensions/VaultExtensionTests.cs b/test/Vault.Tests/Extensions/VaultExtensionTests.cs index fc9c36b..16e9897 100644 --- a/test/Vault.Tests/Extensions/VaultExtensionTests.cs +++ b/test/Vault.Tests/Extensions/VaultExtensionTests.cs @@ -117,41 +117,11 @@ public void AddVault_WithInactivatedVault_RegistersOptionsOnly() Assert.NotNull(registeredOptions); Assert.Same(vaultOptions, registeredOptions); - // VaultService should not be registered + // VaultService should not be registered when Vault is not activated IVaultService? vaultService = serviceProvider.GetService(); Assert.Null(vaultService); } - [Fact] - public void AddVault_WithValidConfiguration_RegistersVaultOptions() - { - // Arrange - var services = new ServiceCollection(); - var configuration = new ConfigurationBuilder(); - IAuthMethodInfo mockAuthMethod = Substitute.For(); - var vaultOptions = new VaultOptions - { - IsActivated = true, - AuthenticationType = VaultAuthenticationType.Custom, - Configuration = new VaultCustomConfiguration - { - VaultUrl = "https://vault.example.com", - MountPoint = "secret", - AuthMethodFactory = () => mockAuthMethod, - }, - }; - var environment = "dev"; - - // Act - services.AddVault(configuration, vaultOptions, environment); - ServiceProvider serviceProvider = services.BuildServiceProvider(); - - // Assert - VaultOptions? registeredOptions = serviceProvider.GetService(); - Assert.NotNull(registeredOptions); - Assert.Same(vaultOptions, registeredOptions); - } - [Fact] public void AddVault_WithInvalidConfiguration_ThrowsInvalidOperationException() { diff --git a/test/Vault.Tests/Services/VaultServiceTests.cs b/test/Vault.Tests/Services/VaultServiceTests.cs index a5af31a..bc4405b 100644 --- a/test/Vault.Tests/Services/VaultServiceTests.cs +++ b/test/Vault.Tests/Services/VaultServiceTests.cs @@ -37,10 +37,9 @@ public void Constructor_WithNullOptions_ThrowsArgumentNullException() } [Fact] - public void Constructor_WithNullLogger_ThrowsArgumentNullException() + public void Constructor_WithNullLogger_UsesNullLogger() { // Arrange - IAuthMethodInfo mockAuthMethod = Substitute.For(); var options = new VaultOptions { IsActivated = true, @@ -49,15 +48,15 @@ public void Constructor_WithNullLogger_ThrowsArgumentNullException() { VaultUrl = "https://vault.example.com", MountPoint = "secret", - AuthMethodFactory = () => mockAuthMethod, + AuthMethodFactory = () => new VaultSharp.V1.AuthMethods.Token.TokenAuthMethodInfo("test-token"), }, }; - ILogger? logger = null; - // Act & Assert - ArgumentNullException exception = Assert.Throws(() => - new VaultService(options, logger!)); - Assert.Equal(nameof(logger), exception.ParamName); + // Act - should not throw, logger is optional + var service = new VaultService(options, null); + + // Assert + Assert.NotNull(service); } [Fact]