feat(configmanager): add runtime config validation using Infrastructure validators
Enable ConfigManager to validate runtime configuration (SecureStore secrets, connection strings, LDAP) using the same validators as the Host application. Adds AddInfrastructureValidators() extension for shared validator registration.
This commit is contained in:
@@ -55,7 +55,31 @@ public static class InfrastructureDependencyInjection
|
||||
services.AddSingleton<IRsaKeyService, SecureStoreRsaKeyService>();
|
||||
|
||||
// Register configuration validators
|
||||
services.AddInfrastructureValidators(configuration);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds only the configuration validators (not other Infrastructure services).
|
||||
/// Used by ConfigManager for runtime validation where SecureStore is managed separately.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">The configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddInfrastructureValidators(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// Bind options that validators depend on
|
||||
services.Configure<SecureStoreOptions>(
|
||||
configuration.GetSection(SecureStoreOptions.SectionName));
|
||||
services.Configure<LdapOptions>(
|
||||
configuration.GetSection(LdapOptions.SectionName));
|
||||
|
||||
// Register validators - new validators added here are automatically discovered
|
||||
services.AddSingleton<IConfigurationValidator, SecureStoreValidator>();
|
||||
services.AddSingleton<IConfigurationValidator, ConnectionStringValidator>();
|
||||
services.AddSingleton<IConfigurationValidator, LdapOptionsValidator>();
|
||||
|
||||
return services;
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using JdeScoping.Core.Interfaces;
|
||||
using JdeScoping.Core.Validation;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace JdeScoping.Infrastructure.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Validates connection strings by resolving SecureStore placeholders and verifying format.
|
||||
/// </summary>
|
||||
public partial class ConnectionStringValidator : IConfigurationValidator
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ISecureStoreService _secureStore;
|
||||
private readonly ILogger<ConnectionStringValidator> _logger;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Order => 150; // After SecureStore (100), before LDAP (200)
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "ConnectionStrings";
|
||||
|
||||
[GeneratedRegex(@"\$\{([^}]+)\}")]
|
||||
private static partial Regex PlaceholderPattern();
|
||||
|
||||
public ConnectionStringValidator(
|
||||
IConfiguration configuration,
|
||||
ISecureStoreService secureStore,
|
||||
ILogger<ConnectionStringValidator> logger)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_secureStore = secureStore;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ConfigurationValidationResult Validate()
|
||||
{
|
||||
var result = new ConfigurationValidationResult(Name);
|
||||
|
||||
var connectionStringsSection = _configuration.GetSection("ConnectionStrings");
|
||||
var connectionStrings = connectionStringsSection.GetChildren().ToList();
|
||||
|
||||
if (connectionStrings.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No connection strings configured, skipping validation");
|
||||
return result;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Validating {Count} connection strings", connectionStrings.Count);
|
||||
|
||||
foreach (var connectionString in connectionStrings)
|
||||
{
|
||||
ValidateConnectionString(connectionString.Key, connectionString.Value, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void ValidateConnectionString(string name, string? connectionString, ConfigurationValidationResult result)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
result.AddError($"Connection string '{name}' is empty");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Validating connection string: {Name}", name);
|
||||
|
||||
// Extract and validate all placeholders
|
||||
var placeholders = ExtractPlaceholders(connectionString);
|
||||
var resolvedConnectionString = connectionString;
|
||||
var hasPlaceholderErrors = false;
|
||||
|
||||
foreach (var placeholder in placeholders)
|
||||
{
|
||||
var resolvedValue = ResolvePlaceholder(placeholder, name, result);
|
||||
if (resolvedValue is null)
|
||||
{
|
||||
hasPlaceholderErrors = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Replace placeholder with resolved value for format validation
|
||||
resolvedConnectionString = resolvedConnectionString.Replace($"${{{placeholder}}}", resolvedValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Only validate format if all placeholders were resolved
|
||||
if (!hasPlaceholderErrors)
|
||||
{
|
||||
ValidateConnectionStringFormat(name, resolvedConnectionString, result);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> ExtractPlaceholders(string connectionString)
|
||||
{
|
||||
var placeholders = new List<string>();
|
||||
var matches = PlaceholderPattern().Matches(connectionString);
|
||||
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
placeholders.Add(match.Groups[1].Value);
|
||||
}
|
||||
|
||||
return placeholders;
|
||||
}
|
||||
|
||||
private string? ResolvePlaceholder(string key, string connectionStringName, ConfigurationValidationResult result)
|
||||
{
|
||||
if (!_secureStore.Contains(key))
|
||||
{
|
||||
result.AddError($"Connection string '{connectionStringName}' references placeholder '${{{key}}}' but key '{key}' not found in SecureStore");
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var value = _secureStore.Get(key);
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
result.AddError($"Connection string '{connectionStringName}' references placeholder '${{{key}}}' but key '{key}' has an empty value in SecureStore");
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Resolved placeholder '{Key}' for connection string '{Name}'", key, connectionStringName);
|
||||
return value;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.AddError($"Connection string '{connectionStringName}' references placeholder '${{{key}}}' but failed to retrieve from SecureStore: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateConnectionStringFormat(string name, string connectionString, ConfigurationValidationResult result)
|
||||
{
|
||||
// Check for required connection string components
|
||||
// Support both "Server=" (SQL Server) and "Data Source=" (Oracle/generic)
|
||||
var hasServer = connectionString.Contains("Server=", StringComparison.OrdinalIgnoreCase) ||
|
||||
connectionString.Contains("Data Source=", StringComparison.OrdinalIgnoreCase) ||
|
||||
connectionString.Contains("Host=", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!hasServer)
|
||||
{
|
||||
result.AddError($"Connection string '{name}' is missing required 'Server=', 'Data Source=', or 'Host=' component");
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect connection type and log info
|
||||
if (connectionString.Contains("Server=", StringComparison.OrdinalIgnoreCase) &&
|
||||
connectionString.Contains("Database=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogDebug("Connection string '{Name}' appears to be SQL Server format", name);
|
||||
}
|
||||
else if (connectionString.Contains("Data Source=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogDebug("Connection string '{Name}' appears to be Oracle/generic format", name);
|
||||
}
|
||||
else if (connectionString.Contains("Host=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogDebug("Connection string '{Name}' appears to be PostgreSQL/generic format", name);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.AddWarning($"Connection string '{name}' has an unknown format");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,9 @@ public partial class App : Avalonia.Application
|
||||
services.AddSingleton<StoreUseCases>();
|
||||
services.AddSingleton<SecretUseCases>();
|
||||
|
||||
// Runtime Validation Services
|
||||
services.AddSingleton<IRuntimeConfigValidationService, RuntimeConfigValidationService>();
|
||||
|
||||
// ViewModels
|
||||
services.AddTransient<MainWindowViewModel>();
|
||||
}
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.*" Condition="'$(Configuration)' == 'Debug'" />
|
||||
<PackageReference Include="DiffPlex" Version="1.7.*" />
|
||||
<PackageReference Include="MessageBox.Avalonia" Version="3.1.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.*" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.*" />
|
||||
<PackageReference Include="SecureStore" Version="1.2.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.*" />
|
||||
@@ -28,5 +30,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\JdeScoping.Core\JdeScoping.Core.csproj" />
|
||||
<ProjectReference Include="..\..\JdeScoping.Infrastructure\JdeScoping.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace JdeScoping.ConfigManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Result of runtime configuration validation.
|
||||
/// </summary>
|
||||
public class RuntimeValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the validator that produced this result.
|
||||
/// </summary>
|
||||
public string ValidatorName { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether validation passed (no errors).
|
||||
/// </summary>
|
||||
public bool IsValid => Errors.Count == 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of validation errors.
|
||||
/// </summary>
|
||||
public List<string> Errors { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of validation warnings.
|
||||
/// </summary>
|
||||
public List<string> Warnings { get; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for validating runtime configuration using Infrastructure validators.
|
||||
/// </summary>
|
||||
public interface IRuntimeConfigValidationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates the configuration in the specified folder using Infrastructure validators.
|
||||
/// </summary>
|
||||
/// <param name="configFolderPath">Path to the configuration folder.</param>
|
||||
/// <returns>List of validation results from each validator.</returns>
|
||||
List<RuntimeValidationResult> ValidateRuntimeConfig(string configFolderPath);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using JdeScoping.ConfigManager.Services.SecureStore;
|
||||
using JdeScoping.Core.Interfaces;
|
||||
using JdeScoping.Core.Validation;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace JdeScoping.ConfigManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service that validates configuration using Infrastructure validators.
|
||||
/// Uses DI-based automatic validator discovery (same pattern as Host).
|
||||
/// </summary>
|
||||
public class RuntimeConfigValidationService : IRuntimeConfigValidationService
|
||||
{
|
||||
private readonly ISecureStoreManager _secureStoreManager;
|
||||
private readonly ILogger<RuntimeConfigValidationService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of the runtime config validation service.
|
||||
/// </summary>
|
||||
/// <param name="secureStoreManager">The SecureStore manager for accessing secrets.</param>
|
||||
/// <param name="logger">Optional logger for diagnostics.</param>
|
||||
public RuntimeConfigValidationService(
|
||||
ISecureStoreManager secureStoreManager,
|
||||
ILogger<RuntimeConfigValidationService>? logger = null)
|
||||
{
|
||||
_secureStoreManager = secureStoreManager;
|
||||
_logger = logger ?? NullLogger<RuntimeConfigValidationService>.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public List<RuntimeValidationResult> ValidateRuntimeConfig(string configFolderPath)
|
||||
{
|
||||
var results = new List<RuntimeValidationResult>();
|
||||
|
||||
// Build IConfiguration from appsettings.json
|
||||
var appSettingsPath = Path.Combine(configFolderPath, "appsettings.json");
|
||||
if (!File.Exists(appSettingsPath))
|
||||
{
|
||||
var configResult = new RuntimeValidationResult { ValidatorName = "Configuration" };
|
||||
configResult.Errors.Add($"appsettings.json not found at: {appSettingsPath}");
|
||||
results.Add(configResult);
|
||||
return results;
|
||||
}
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.SetBasePath(configFolderPath)
|
||||
.AddJsonFile("appsettings.json", optional: false)
|
||||
.Build();
|
||||
|
||||
// Check if SecureStore is open
|
||||
if (!_secureStoreManager.IsStoreOpen)
|
||||
{
|
||||
var storeWarning = new RuntimeValidationResult { ValidatorName = "SecureStore" };
|
||||
storeWarning.Warnings.Add("No SecureStore is currently open. SecureStore-dependent validations will be skipped.");
|
||||
results.Add(storeWarning);
|
||||
}
|
||||
|
||||
// Build a scoped service provider for validators (automatic discovery)
|
||||
using var validatorServices = BuildValidatorServiceProvider(configuration);
|
||||
|
||||
// Discover validators automatically via DI
|
||||
var validators = validatorServices
|
||||
.GetServices<IConfigurationValidator>()
|
||||
.OrderBy(v => v.Order)
|
||||
.ToList();
|
||||
|
||||
_logger.LogDebug("Discovered {Count} configuration validators", validators.Count);
|
||||
|
||||
foreach (var validator in validators)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Running validator: {Name} (Order={Order})", validator.Name, validator.Order);
|
||||
var validationResult = validator.Validate();
|
||||
var runtimeResult = new RuntimeValidationResult { ValidatorName = validationResult.ValidatorName };
|
||||
runtimeResult.Errors.AddRange(validationResult.Errors);
|
||||
runtimeResult.Warnings.AddRange(validationResult.Warnings);
|
||||
results.Add(runtimeResult);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Validator {Name} threw exception", validator.Name);
|
||||
var errorResult = new RuntimeValidationResult { ValidatorName = validator.Name };
|
||||
errorResult.Errors.Add($"Validator threw exception: {ex.Message}");
|
||||
results.Add(errorResult);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a scoped service provider with validators and their dependencies.
|
||||
/// New validators added via AddInfrastructureValidators() are automatically discovered.
|
||||
/// </summary>
|
||||
private ServiceProvider BuildValidatorServiceProvider(IConfiguration configuration)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Register core services that validators depend on
|
||||
services.AddSingleton(configuration);
|
||||
services.AddSingleton<ISecureStoreService>(new SecureStoreServiceAdapter(_secureStoreManager));
|
||||
services.AddLogging();
|
||||
|
||||
// Register validators using the same extension method as Host
|
||||
// This ensures new validators are automatically discovered
|
||||
services.AddInfrastructureValidators(configuration);
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using JdeScoping.ConfigManager.Services.SecureStore;
|
||||
using JdeScoping.Core.Interfaces;
|
||||
|
||||
namespace JdeScoping.ConfigManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Adapts ISecureStoreManager to ISecureStoreService for use by Infrastructure validators.
|
||||
/// </summary>
|
||||
public class SecureStoreServiceAdapter : ISecureStoreService
|
||||
{
|
||||
private readonly ISecureStoreManager _manager;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new adapter wrapping the specified manager.
|
||||
/// </summary>
|
||||
/// <param name="manager">The SecureStore manager to adapt.</param>
|
||||
public SecureStoreServiceAdapter(ISecureStoreManager manager)
|
||||
{
|
||||
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? Get(string key)
|
||||
{
|
||||
if (!_manager.IsStoreOpen) return null;
|
||||
try { return _manager.GetSecret(key); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetRequired(string key)
|
||||
{
|
||||
if (!_manager.IsStoreOpen)
|
||||
throw new InvalidOperationException("No SecureStore is currently open");
|
||||
return _manager.GetSecret(key);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Set(string key, string value) => _manager.SetSecret(key, value);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Contains(string key)
|
||||
{
|
||||
if (!_manager.IsStoreOpen) return false;
|
||||
return _manager.GetKeys().Contains(key);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Remove(string key)
|
||||
{
|
||||
if (!_manager.IsStoreOpen) return false;
|
||||
try { _manager.RemoveSecret(key); return true; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Save() => _manager.Save();
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<string> Keys => _manager.IsStoreOpen ? _manager.GetKeys() : [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
// Manager lifecycle handled elsewhere - we don't own it
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ public class MainWindowViewModel : ViewModelBase
|
||||
private readonly IDialogService? _dialogService;
|
||||
private readonly ISecureStoreManager _secureStoreManager;
|
||||
private readonly IClipboardService _clipboardService;
|
||||
private readonly IRuntimeConfigValidationService _runtimeValidationService;
|
||||
private readonly ILogger<MainWindowViewModel>? _logger;
|
||||
|
||||
private string _configFolderPath = "No folder selected";
|
||||
@@ -193,6 +194,11 @@ public class MainWindowViewModel : ViewModelBase
|
||||
/// </summary>
|
||||
public ICommand DeletePipelineCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command for validating runtime configuration using Infrastructure validators.
|
||||
/// </summary>
|
||||
public ICommand ValidateRuntimeConfigCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MainWindowViewModel"/> class.
|
||||
/// </summary>
|
||||
@@ -204,6 +210,7 @@ public class MainWindowViewModel : ViewModelBase
|
||||
/// <param name="dialogService">Service for showing platform dialogs.</param>
|
||||
/// <param name="secureStoreManager">Service for managing encrypted secret stores.</param>
|
||||
/// <param name="clipboardService">Service for clipboard operations.</param>
|
||||
/// <param name="runtimeValidationService">Service for runtime configuration validation.</param>
|
||||
/// <param name="logger">Optional logger for recording view model activities.</param>
|
||||
public MainWindowViewModel(
|
||||
IFileSystem fileSystem,
|
||||
@@ -214,6 +221,7 @@ public class MainWindowViewModel : ViewModelBase
|
||||
IDialogService? dialogService,
|
||||
ISecureStoreManager secureStoreManager,
|
||||
IClipboardService clipboardService,
|
||||
IRuntimeConfigValidationService runtimeValidationService,
|
||||
ILogger<MainWindowViewModel>? logger)
|
||||
{
|
||||
_fileSystem = fileSystem;
|
||||
@@ -224,6 +232,7 @@ public class MainWindowViewModel : ViewModelBase
|
||||
_dialogService = dialogService;
|
||||
_secureStoreManager = secureStoreManager;
|
||||
_clipboardService = clipboardService;
|
||||
_runtimeValidationService = runtimeValidationService;
|
||||
_logger = logger;
|
||||
|
||||
OpenFolderCommand = new AsyncRelayCommand(OpenFolderAsync);
|
||||
@@ -249,6 +258,11 @@ public class MainWindowViewModel : ViewModelBase
|
||||
AddPipelineCommand = new AsyncRelayCommand(AddPipelineAsync, CanAddPipeline);
|
||||
DeletePipelineCommand = new AsyncRelayCommand(DeletePipelineAsync, CanDeletePipeline);
|
||||
|
||||
// Validation commands
|
||||
ValidateRuntimeConfigCommand = new AsyncRelayCommand(
|
||||
ValidateRuntimeConfigAsync,
|
||||
() => ConfigFolderPath != "No folder selected");
|
||||
|
||||
_ = InitializeAsync();
|
||||
}
|
||||
|
||||
@@ -264,6 +278,7 @@ public class MainWindowViewModel : ViewModelBase
|
||||
null,
|
||||
new SecureStoreManager(),
|
||||
new NullClipboardService(),
|
||||
new RuntimeConfigValidationService(new SecureStoreManager()),
|
||||
null)
|
||||
{
|
||||
}
|
||||
@@ -746,6 +761,31 @@ public class MainWindowViewModel : ViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates runtime configuration using Infrastructure validators (SecureStore, Connection Strings, LDAP).
|
||||
/// </summary>
|
||||
private async Task ValidateRuntimeConfigAsync()
|
||||
{
|
||||
if (_dialogService == null || ConfigFolderPath == "No folder selected")
|
||||
return;
|
||||
|
||||
var results = _runtimeValidationService.ValidateRuntimeConfig(ConfigFolderPath);
|
||||
|
||||
// Convert to ValidationResult for dialog
|
||||
var appSettingsResult = new ValidationResult();
|
||||
var pipelinesResult = new ValidationResult(); // Will remain empty for runtime validation
|
||||
|
||||
foreach (var result in results)
|
||||
{
|
||||
foreach (var error in result.Errors)
|
||||
appSettingsResult.AddError($"[{result.ValidatorName}] {error}");
|
||||
foreach (var warning in result.Warnings)
|
||||
appSettingsResult.AddWarning($"[{result.ValidatorName}] {warning}");
|
||||
}
|
||||
|
||||
await _dialogService.ShowValidationResultsAsync(appSettingsResult, pipelinesResult);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests database connections defined in the configuration.
|
||||
/// </summary>
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
<MenuItem Header="_Tools">
|
||||
<MenuItem Header="_Validate All" Command="{Binding ValidateCommand}" InputGesture="F5"/>
|
||||
<MenuItem Header="_Test Connection" Command="{Binding TestConnectionCommand}" InputGesture="F6"/>
|
||||
<MenuItem Header="Validate _Runtime Config" Command="{Binding ValidateRuntimeConfigCommand}" InputGesture="F7"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="View _Backups..."/>
|
||||
</MenuItem>
|
||||
@@ -154,6 +155,8 @@
|
||||
<Border Width="1" Height="20" Background="#2D3540" Margin="4,0"/>
|
||||
<Button Content="Test" Command="{Binding TestConnectionCommand}" Classes="toolbar"/>
|
||||
<Button Content="Validate" Command="{Binding ValidateCommand}" Classes="toolbar"/>
|
||||
<Button Content="Validate Runtime" Command="{Binding ValidateRuntimeConfigCommand}"
|
||||
ToolTip.Tip="Validate SecureStore, Connection Strings, LDAP" Classes="toolbar"/>
|
||||
<Border Width="1" Height="20" Background="#2D3540" Margin="4,0"/>
|
||||
<Button Content="+ Pipeline" Command="{Binding AddPipelineCommand}" ToolTip.Tip="Add Pipeline" Classes="toolbar"/>
|
||||
<Border Width="1" Height="20" Background="#2D3540" Margin="4,0"/>
|
||||
|
||||
@@ -18,6 +18,7 @@ public class MainWindowViewModelTests
|
||||
private readonly IDialogService _dialogService;
|
||||
private readonly ISecureStoreManager _secureStoreManager;
|
||||
private readonly IClipboardService _clipboardService;
|
||||
private readonly IRuntimeConfigValidationService _runtimeValidationService;
|
||||
private readonly ILogger<MainWindowViewModel> _logger;
|
||||
|
||||
public MainWindowViewModelTests()
|
||||
@@ -30,6 +31,7 @@ public class MainWindowViewModelTests
|
||||
_dialogService = Substitute.For<IDialogService>();
|
||||
_secureStoreManager = Substitute.For<ISecureStoreManager>();
|
||||
_clipboardService = Substitute.For<IClipboardService>();
|
||||
_runtimeValidationService = Substitute.For<IRuntimeConfigValidationService>();
|
||||
_logger = Substitute.For<ILogger<MainWindowViewModel>>();
|
||||
|
||||
_validationService.ValidateAppSettings(Arg.Any<ConfigModel>())
|
||||
@@ -159,7 +161,7 @@ public class MainWindowViewModelTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectingPipelineNode_LoadsPipelineFormViewModel()
|
||||
public void SelectingPipelineNode_LoadsPipelineEditorViewModel()
|
||||
{
|
||||
// Arrange
|
||||
var config = new ConfigModel();
|
||||
@@ -185,10 +187,10 @@ public class MainWindowViewModelTests
|
||||
sut.SelectedNode = pipelineNode;
|
||||
|
||||
// Assert
|
||||
sut.SelectedFormViewModel.ShouldBeOfType<PipelineFormViewModel>();
|
||||
var pipelineForm = (PipelineFormViewModel)sut.SelectedFormViewModel!;
|
||||
pipelineForm.Name.ShouldBe("WorkOrders");
|
||||
pipelineForm.Connection.ShouldBe("jde");
|
||||
sut.SelectedFormViewModel.ShouldBeOfType<PipelineEditorViewModel>();
|
||||
var pipelineEditor = (PipelineEditorViewModel)sut.SelectedFormViewModel!;
|
||||
pipelineEditor.Name.ShouldBe("WorkOrders");
|
||||
pipelineEditor.Source.Connection.ShouldBe("jde");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -318,6 +320,7 @@ public class MainWindowViewModelTests
|
||||
_dialogService,
|
||||
_secureStoreManager,
|
||||
_clipboardService,
|
||||
_runtimeValidationService,
|
||||
_logger);
|
||||
}
|
||||
}
|
||||
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
using JdeScoping.Infrastructure.Tests.Helpers;
|
||||
using JdeScoping.Infrastructure.Validation;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace JdeScoping.Infrastructure.Tests.Validation;
|
||||
|
||||
public class ConnectionStringValidatorTests : IDisposable
|
||||
{
|
||||
private readonly InMemorySecureStore _secureStore;
|
||||
|
||||
public ConnectionStringValidatorTests()
|
||||
{
|
||||
_secureStore = new InMemorySecureStore();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_secureStore.Dispose();
|
||||
}
|
||||
|
||||
private ConnectionStringValidator CreateValidator(Dictionary<string, string?> configValues)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configValues)
|
||||
.Build();
|
||||
|
||||
return new ConnectionStringValidator(
|
||||
configuration,
|
||||
_secureStore,
|
||||
NullLogger<ConnectionStringValidator>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Order_Returns150()
|
||||
{
|
||||
// Arrange
|
||||
var validator = CreateValidator(new Dictionary<string, string?>());
|
||||
|
||||
// Assert
|
||||
validator.Order.ShouldBe(150);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsConnectionStrings()
|
||||
{
|
||||
// Arrange
|
||||
var validator = CreateValidator(new Dictionary<string, string?>());
|
||||
|
||||
// Assert
|
||||
validator.Name.ShouldBe("ConnectionStrings");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NoConnectionStrings_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var validator = CreateValidator(new Dictionary<string, string?>());
|
||||
|
||||
// Act
|
||||
var result = validator.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Errors.ShouldBeEmpty();
|
||||
result.Warnings.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ConnectionStringWithNoPlaceholders_ValidatesFormat()
|
||||
{
|
||||
// Arrange
|
||||
var validator = CreateValidator(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:SqlServer"] = "Server=localhost;Database=TestDb;Trusted_Connection=true;"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = validator.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyConnectionString_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var validator = CreateValidator(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:SqlServer"] = ""
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = validator.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain("Connection string 'SqlServer' is empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_PlaceholderKeyNotInSecureStore_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var validator = CreateValidator(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:SqlServer"] = "Server=localhost;Database=TestDb;Password=${DB_PASSWORD};"
|
||||
});
|
||||
// Note: Not adding DB_PASSWORD to SecureStore
|
||||
|
||||
// Act
|
||||
var result = validator.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("'${DB_PASSWORD}'") && e.Contains("not found in SecureStore"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_PlaceholderKeyHasEmptyValue_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
_secureStore.Set("DB_PASSWORD", "");
|
||||
var validator = CreateValidator(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:SqlServer"] = "Server=localhost;Database=TestDb;Password=${DB_PASSWORD};"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = validator.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("'${DB_PASSWORD}'") && e.Contains("empty value"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AllPlaceholdersResolved_ValidatesFormat()
|
||||
{
|
||||
// Arrange
|
||||
_secureStore.Set("DB_PASSWORD", "secretpassword");
|
||||
var validator = CreateValidator(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:SqlServer"] = "Server=localhost;Database=TestDb;Password=${DB_PASSWORD};"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = validator.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_MultiplePlaceholders_ResolvesAll()
|
||||
{
|
||||
// Arrange
|
||||
_secureStore.Set("DB_SERVER", "prodserver.local");
|
||||
_secureStore.Set("DB_USER", "app_user");
|
||||
_secureStore.Set("DB_PASSWORD", "secretpassword");
|
||||
var validator = CreateValidator(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:SqlServer"] = "Server=${DB_SERVER};Database=TestDb;User Id=${DB_USER};Password=${DB_PASSWORD};"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = validator.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_MultiplePlaceholders_ReportsAllMissing()
|
||||
{
|
||||
// Arrange - only set one of three required placeholders
|
||||
_secureStore.Set("DB_SERVER", "prodserver.local");
|
||||
var validator = CreateValidator(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:SqlServer"] = "Server=${DB_SERVER};Database=TestDb;User Id=${DB_USER};Password=${DB_PASSWORD};"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = validator.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.Count.ShouldBe(2); // DB_USER and DB_PASSWORD missing
|
||||
result.Errors.ShouldContain(e => e.Contains("'${DB_USER}'"));
|
||||
result.Errors.ShouldContain(e => e.Contains("'${DB_PASSWORD}'"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_MissingServerOrDataSource_ReturnsError()
|
||||
{
|
||||
// Arrange - connection string with no Server= or Data Source=
|
||||
var validator = CreateValidator(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:Invalid"] = "Database=TestDb;Trusted_Connection=true;"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = validator.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("'Invalid'") && e.Contains("missing required"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DataSourceFormat_ValidatesSuccessfully()
|
||||
{
|
||||
// Arrange - Oracle-style connection string
|
||||
var validator = CreateValidator(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:Oracle"] = "Data Source=//localhost:1521/ORCL;User Id=test;Password=test;"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = validator.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_HostFormat_ValidatesSuccessfully()
|
||||
{
|
||||
// Arrange - PostgreSQL-style connection string
|
||||
var validator = CreateValidator(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:Postgres"] = "Host=localhost;Port=5432;Database=testdb;Username=test;Password=test;"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = validator.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_MultipleConnectionStrings_ValidatesAll()
|
||||
{
|
||||
// Arrange
|
||||
_secureStore.Set("SQL_PASSWORD", "sqlpass");
|
||||
var validator = CreateValidator(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:SqlServer"] = "Server=localhost;Database=TestDb;Password=${SQL_PASSWORD};",
|
||||
["ConnectionStrings:Oracle"] = "Data Source=//localhost:1521/ORCL;User Id=test;Password=test;",
|
||||
["ConnectionStrings:InvalidEmpty"] = ""
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = validator.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.Count.ShouldBe(1); // Only the empty one should fail
|
||||
result.Errors.ShouldContain("Connection string 'InvalidEmpty' is empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_UnknownConnectionFormat_AddsWarning()
|
||||
{
|
||||
// Arrange - connection string with Server= but no Database=
|
||||
var validator = CreateValidator(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:Custom"] = "Server=localhost;CustomProperty=value;"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = validator.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeTrue(); // Still valid, just a warning
|
||||
result.Warnings.ShouldContain(w => w.Contains("'Custom'") && w.Contains("unknown format"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhitespaceOnlyConnectionString_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var validator = CreateValidator(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:SqlServer"] = " "
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = validator.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain("Connection string 'SqlServer' is empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_CaseInsensitiveServerCheck_ValidatesSuccessfully()
|
||||
{
|
||||
// Arrange - mixed case Server
|
||||
var validator = CreateValidator(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:SqlServer"] = "server=localhost;Database=TestDb;"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = validator.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user