diff --git a/.gitignore b/.gitignore index a93dd0b..5d21288 100644 --- a/.gitignore +++ b/.gitignore @@ -78,7 +78,10 @@ coverage*.xml !.env.example appsettings.*.json !appsettings.json -!appsettings.Development.json + +# YAML config - production secrets must never be committed +OrderMonitor_ENV.production.yml +OrderMonitor_ENV.staging.yml # Secrets *.pem diff --git a/OrderMonitor_ENV.development.yml b/OrderMonitor_ENV.development.yml new file mode 100644 index 0000000..7dda572 --- /dev/null +++ b/OrderMonitor_ENV.development.yml @@ -0,0 +1,46 @@ +# OrderMonitor API - Development Environment Configuration +# Non-sensitive values for local development. +# Sensitive values (passwords, keys) should be set as OS environment variables. + +# ===== Database Configuration ===== +Database__Provider: "sqlserver" +Database__ConnectionString: "Server=localhost,1433;Database=PrinterPix_BO_Live;User Id=sa;Password={ENCRYPTED};TrustServerCertificate=True;ApplicationIntent=ReadOnly;" +Database__EncryptedPassword: "" # Set via env var: Database__EncryptedPassword +Database__EncryptionKey: "" # Set via env var: Database__EncryptionKey +Database__MaxPoolSize: "50" +Database__CommandTimeout: "30" + +# ===== SMTP Configuration ===== +SmtpSettings__Host: "pod51017.outlook.com" +SmtpSettings__Port: "587" +SmtpSettings__Username: "backoffice@printerpix.com" +SmtpSettings__Password: "" # Set via env var: SmtpSettings__Password +SmtpSettings__FromEmail: "backoffice@printerpix.com" +SmtpSettings__UseSsl: "true" + +# ===== Alert Configuration ===== +Alerts__Enabled: "true" +Alerts__Recipients: "ranganathan.e@syncoms.com" +Alerts__SubjectPrefix: "[Order Monitor DEV]" + +# ===== Scanner Configuration ===== +Scanner__Enabled: "true" +Scanner__IntervalMinutes: "15" +Scanner__BatchSize: "100" + +# ===== Business Hours ===== +BusinessHours__Timezone: "Europe/London" +BusinessHours__StartHour: "0" +BusinessHours__EndHour: "0" +BusinessHours__Holidays: "2026-01-01,2026-04-03,2026-04-06,2026-05-04,2026-05-25,2026-08-31,2026-12-25,2026-12-28" + +# ===== Swagger ===== +Swagger__Enabled: "true" + +# ===== Logging ===== +Logging__LogLevel__Default: "Debug" +Logging__LogLevel__Microsoft.AspNetCore: "Information" +Logging__LogLevel__OrderMonitor: "Debug" + +# ===== Application ===== +ASPNETCORE_ENVIRONMENT: "Development" diff --git a/OrderMonitor_ENV.secrets.yml.template b/OrderMonitor_ENV.secrets.yml.template new file mode 100644 index 0000000..f599845 --- /dev/null +++ b/OrderMonitor_ENV.secrets.yml.template @@ -0,0 +1,25 @@ +# OrderMonitor API - Secrets Configuration Template +# ================================================= +# IMPORTANT: Never commit this file with real values! +# Copy to OrderMonitor_ENV.secrets.yml and fill with actual Base64-encoded values. +# This file is for reference only. +# +# How to Base64 encode: +# Linux/Mac: echo -n "your-value" | base64 +# PowerShell: [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("your-value")) + +# ===== Database Credentials ===== +# Connection string with password placeholder +Database__ConnectionString: "" +# AES-encrypted database password (Base64) +Database__EncryptedPassword: "" +# AES-256 encryption key (exactly 32 characters) +Database__EncryptionKey: "" + +# ===== SMTP Credentials ===== +# Encrypted SMTP password (Base64 AES-encrypted) +SmtpSettings__Password: "" + +# ===== Alert Recipients ===== +# Comma-separated email addresses +Alerts__Recipients: "" diff --git a/OrderMonitor_ENV.yml b/OrderMonitor_ENV.yml new file mode 100644 index 0000000..52541fa --- /dev/null +++ b/OrderMonitor_ENV.yml @@ -0,0 +1,65 @@ +# OrderMonitor API - Environment Configuration +# This file contains ALL configuration values for the application. +# Override precedence: K8s Secrets > ConfigMap > OS env vars > this file +# +# Key naming: Use double-underscore (__) as hierarchy separator. +# Example: Database__ConnectionString maps to IConfiguration["Database:ConnectionString"] + +# ===== Database Configuration ===== +Database__Provider: "sqlserver" # sqlserver | mysql | postgresql +Database__ConnectionString: "" # Full connection string (without password if using EncryptedPassword) +Database__EncryptedPassword: "" # Base64-encoded AES-encrypted password +Database__EncryptionKey: "" # AES-256 key for decrypting EncryptedPassword (32 chars) +Database__MaxPoolSize: "100" +Database__CommandTimeout: "30" + +# ===== SMTP Configuration ===== +SmtpSettings__Host: "" +SmtpSettings__Port: "587" +SmtpSettings__Username: "" +SmtpSettings__Password: "" # Encrypted SMTP password +SmtpSettings__FromEmail: "" +SmtpSettings__UseSsl: "true" + +# ===== Alert Configuration ===== +Alerts__Enabled: "true" +Alerts__Recipients: "" # Comma-separated email addresses +Alerts__SubjectPrefix: "[Order Monitor]" + +# ===== Scanner Configuration ===== +Scanner__Enabled: "true" +Scanner__IntervalMinutes: "15" +Scanner__BatchSize: "1000" + +# ===== Status Thresholds ===== +StatusThresholds__PrepStatuses__MinStatusId: "3001" +StatusThresholds__PrepStatuses__MaxStatusId: "3910" +StatusThresholds__PrepStatuses__ThresholdHours: "6" +StatusThresholds__FacilityStatuses__MinStatusId: "4001" +StatusThresholds__FacilityStatuses__MaxStatusId: "5830" +StatusThresholds__FacilityStatuses__ThresholdHours: "48" + +# ===== Business Hours ===== +BusinessHours__Timezone: "Europe/London" +BusinessHours__StartHour: "0" # 0 = full 24-hour days +BusinessHours__EndHour: "0" +BusinessHours__Holidays: "" # Comma-separated yyyy-MM-dd dates + +# ===== Health Check ===== +HealthCheck__Path: "/health" +HealthCheck__IncludeDatabase: "true" + +# ===== Swagger ===== +Swagger__Enabled: "true" +Swagger__Title: "OrderMonitor API" +Swagger__Version: "v1" + +# ===== Logging ===== +Logging__LogLevel__Default: "Information" +Logging__LogLevel__Microsoft.AspNetCore: "Warning" +Logging__LogLevel__OrderMonitor: "Debug" + +# ===== Application ===== +AllowedHosts: "*" +ASPNETCORE_ENVIRONMENT: "Production" +ASPNETCORE_URLS: "http://+:8080" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ef0b015 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,44 @@ +version: '3.8' + +services: + ordermonitor-api: + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Development + volumes: + - ./OrderMonitor_ENV.yml:/app/OrderMonitor_ENV.yml:ro + - ./OrderMonitor_ENV.development.yml:/app/OrderMonitor_ENV.development.yml:ro + depends_on: + sqlserver: + condition: service_healthy + networks: + - ordermonitor + + sqlserver: + image: mcr.microsoft.com/mssql/server:2022-latest + environment: + - ACCEPT_EULA=Y + - MSSQL_SA_PASSWORD=YourStr0ngP@ssword! + ports: + - "1433:1433" + volumes: + - sqlserver-data:/var/opt/mssql + healthcheck: + test: /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "YourStr0ngP@ssword!" -Q "SELECT 1" -C || exit 1 + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + networks: + - ordermonitor + +volumes: + sqlserver-data: + +networks: + ordermonitor: + driver: bridge diff --git a/src/OrderMonitor.Api/Program.cs b/src/OrderMonitor.Api/Program.cs index 5ed7e67..e98f94d 100644 --- a/src/OrderMonitor.Api/Program.cs +++ b/src/OrderMonitor.Api/Program.cs @@ -1,4 +1,6 @@ +using OrderMonitor.Core.Interfaces; using OrderMonitor.Infrastructure; +using OrderMonitor.Infrastructure.Configuration; using Serilog; // Configure Serilog early for startup logging @@ -12,6 +14,14 @@ var builder = WebApplication.CreateBuilder(args); + // Add YAML configuration sources (before builder.Build) + // Override precedence: appsettings.json → YML file → environment variables + var environment = builder.Environment.EnvironmentName; + builder.Configuration + .AddYamlFile("OrderMonitor_ENV.yml", optional: true) + .AddYamlFile($"OrderMonitor_ENV.{environment.ToLowerInvariant()}.yml", optional: true) + .AddEnvironmentVariables(); + // Configure Serilog from appsettings builder.Host.UseSerilog((context, services, configuration) => configuration .ReadFrom.Configuration(context.Configuration) @@ -24,7 +34,7 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); - // Add infrastructure services (database, repositories) + // Add infrastructure services (database, repositories, config validation) builder.Services.AddInfrastructure(builder.Configuration); // Add health checks @@ -32,6 +42,10 @@ var app = builder.Build(); + // Validate configuration at startup + var validator = app.Services.GetService(); + validator?.Validate(); + // Configure the HTTP request pipeline if (app.Environment.IsDevelopment()) { diff --git a/src/OrderMonitor.Api/appsettings.Development.json b/src/OrderMonitor.Api/appsettings.Development.json deleted file mode 100644 index bccc103..0000000 --- a/src/OrderMonitor.Api/appsettings.Development.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "ConnectionStrings": { - "BackofficeDb": "Server=10.10.30.7,1433;Database=PrinterPix_BO_Live;User Id=db_PIX_BackOffice_Live;Password={ENCRYPTED};TrustServerCertificate=True;ApplicationIntent=ReadOnly;" - }, - - "DatabaseSettings": { - "EncryptedPassword": "BtB8i+odFInds4FDG1smnzWrLl2E5KwJsQLeoW0HzIc=" - }, - - "Scanner": { - "Enabled": true, - "IntervalMinutes": 15, - "BatchSize": 100 - }, - - "SmtpSettings": { - "Host": "pod51017.outlook.com", - "Port": 587, - "Username": "backoffice@printerpix.com", - "Password": "sQUUFnz1eAFXgJ9J3U5hv0E7R8rZwKajIkQNUnevFck=", - "FromEmail": "backoffice@printerpix.com", - "UseSsl": true - }, - - "Alerts": { - "Enabled": true, - "Recipients": ["ranganathan.e@syncoms.com"], - "SubjectPrefix": "[Order Monitor]" - }, - - "Logging": { - "LogLevel": { - "Default": "Debug", - "Microsoft.AspNetCore": "Information", - "OrderMonitor": "Debug" - } - } -} diff --git a/src/OrderMonitor.Api/appsettings.json b/src/OrderMonitor.Api/appsettings.json index cbacd5e..9f7cfe4 100644 --- a/src/OrderMonitor.Api/appsettings.json +++ b/src/OrderMonitor.Api/appsettings.json @@ -1,8 +1,4 @@ { - "ConnectionStrings": { - "BackofficeDb": "Server=localhost;Database=Backoffice;Trusted_Connection=True;TrustServerCertificate=True;ApplicationIntent=ReadOnly;" - }, - "StatusThresholds": { "PrepStatuses": { "MinStatusId": 3001, @@ -16,33 +12,10 @@ } }, - "Scanner": { - "Enabled": true, - "IntervalMinutes": 15, - "BatchSize": 1000 - }, - - "Alerts": { - "Enabled": true, - "Recipients": [ - "ranganathan.e@syncoms.com" - ], - "SubjectPrefix": "[Order Monitor]" - }, - - "SmtpSettings": { - "Host": "pod51017.outlook.com", - "Port": 587, - "Username": "backoffice@printerpix.com", - "FromEmail": "backoffice@printerpix.com", - "UseSsl": true - }, - "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "OrderMonitor": "Debug" + "Microsoft.AspNetCore": "Warning" } }, diff --git a/src/OrderMonitor.Core/Configuration/BusinessHoursSettings.cs b/src/OrderMonitor.Core/Configuration/BusinessHoursSettings.cs new file mode 100644 index 0000000..ab438a3 --- /dev/null +++ b/src/OrderMonitor.Core/Configuration/BusinessHoursSettings.cs @@ -0,0 +1,44 @@ +namespace OrderMonitor.Core.Configuration; + +/// +/// Business hours and holiday configuration settings. +/// +public class BusinessHoursSettings +{ + public const string SectionName = "BusinessHours"; + + /// + /// IANA timezone identifier (e.g., "Europe/London"). + /// + public string Timezone { get; set; } = "Europe/London"; + + /// + /// Business day start hour (24h format). + /// + public int StartHour { get; set; } = 0; + + /// + /// Business day end hour (24h format). 0 means full 24-hour days. + /// + public int EndHour { get; set; } = 0; + + /// + /// Comma-separated list of holiday dates in yyyy-MM-dd format. + /// + public string Holidays { get; set; } = string.Empty; + + /// + /// Parses the Holidays string into a list of DateTime values. + /// + public IEnumerable GetHolidayDates() + { + if (string.IsNullOrWhiteSpace(Holidays)) + return Enumerable.Empty(); + + return Holidays + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => DateTime.TryParse(s, out var date) ? date : (DateTime?)null) + .Where(d => d.HasValue) + .Select(d => d!.Value.Date); + } +} diff --git a/src/OrderMonitor.Core/Configuration/DatabaseSettings.cs b/src/OrderMonitor.Core/Configuration/DatabaseSettings.cs new file mode 100644 index 0000000..6cae63c --- /dev/null +++ b/src/OrderMonitor.Core/Configuration/DatabaseSettings.cs @@ -0,0 +1,40 @@ +namespace OrderMonitor.Core.Configuration; + +/// +/// Database connection configuration settings. +/// +public class DatabaseSettings +{ + public const string SectionName = "Database"; + + /// + /// Database provider: sqlserver, mysql, or postgresql. + /// + public string Provider { get; set; } = "sqlserver"; + + /// + /// Database connection string. + /// + public string ConnectionString { get; set; } = string.Empty; + + /// + /// Encrypted database password (Base64-encoded AES). + /// + public string? EncryptedPassword { get; set; } + + /// + /// AES encryption key for decrypting EncryptedPassword. + /// Must be provided via environment variable — never hardcoded. + /// + public string? EncryptionKey { get; set; } + + /// + /// Maximum connection pool size. + /// + public int MaxPoolSize { get; set; } = 100; + + /// + /// Command timeout in seconds. + /// + public int CommandTimeout { get; set; } = 30; +} diff --git a/src/OrderMonitor.Core/Configuration/HealthCheckSettings.cs b/src/OrderMonitor.Core/Configuration/HealthCheckSettings.cs new file mode 100644 index 0000000..75f0f7b --- /dev/null +++ b/src/OrderMonitor.Core/Configuration/HealthCheckSettings.cs @@ -0,0 +1,19 @@ +namespace OrderMonitor.Core.Configuration; + +/// +/// Health check endpoint configuration settings. +/// +public class HealthCheckSettings +{ + public const string SectionName = "HealthCheck"; + + /// + /// Health check endpoint path. + /// + public string Path { get; set; } = "/health"; + + /// + /// Whether to include database connectivity in health checks. + /// + public bool IncludeDatabase { get; set; } = true; +} diff --git a/src/OrderMonitor.Core/Configuration/SwaggerSettings.cs b/src/OrderMonitor.Core/Configuration/SwaggerSettings.cs new file mode 100644 index 0000000..4e52c2c --- /dev/null +++ b/src/OrderMonitor.Core/Configuration/SwaggerSettings.cs @@ -0,0 +1,24 @@ +namespace OrderMonitor.Core.Configuration; + +/// +/// Swagger/OpenAPI documentation configuration settings. +/// +public class SwaggerSettings +{ + public const string SectionName = "Swagger"; + + /// + /// Whether Swagger UI is enabled. + /// + public bool Enabled { get; set; } = true; + + /// + /// API title shown in Swagger UI. + /// + public string Title { get; set; } = "OrderMonitor API"; + + /// + /// API version shown in Swagger UI. + /// + public string Version { get; set; } = "v1"; +} diff --git a/src/OrderMonitor.Core/Interfaces/IConfigurationValidator.cs b/src/OrderMonitor.Core/Interfaces/IConfigurationValidator.cs new file mode 100644 index 0000000..d765cad --- /dev/null +++ b/src/OrderMonitor.Core/Interfaces/IConfigurationValidator.cs @@ -0,0 +1,13 @@ +namespace OrderMonitor.Core.Interfaces; + +/// +/// Validates that all required configuration values are present and valid at startup. +/// +public interface IConfigurationValidator +{ + /// + /// Validates all required configuration values. + /// Throws with a clear message if validation fails. + /// + void Validate(); +} diff --git a/src/OrderMonitor.Core/Interfaces/IYamlConfigLoader.cs b/src/OrderMonitor.Core/Interfaces/IYamlConfigLoader.cs new file mode 100644 index 0000000..c56fb02 --- /dev/null +++ b/src/OrderMonitor.Core/Interfaces/IYamlConfigLoader.cs @@ -0,0 +1,15 @@ +namespace OrderMonitor.Core.Interfaces; + +/// +/// Loads configuration from a YAML file and returns a flat key-value map. +/// +public interface IYamlConfigLoader +{ + /// + /// Loads configuration values from a YAML file. + /// Keys use double-underscore (__) as hierarchy separator, which maps to ASP.NET Core's colon (:) separator. + /// + /// Path to the YAML file. + /// Flat dictionary of configuration key-value pairs. + IDictionary Load(string filePath); +} diff --git a/src/OrderMonitor.Core/OrderMonitor.Core.csproj b/src/OrderMonitor.Core/OrderMonitor.Core.csproj index fa71b7a..ee55594 100644 --- a/src/OrderMonitor.Core/OrderMonitor.Core.csproj +++ b/src/OrderMonitor.Core/OrderMonitor.Core.csproj @@ -6,4 +6,8 @@ enable + + + + diff --git a/src/OrderMonitor.Core/Services/BusinessHoursCalculator.cs b/src/OrderMonitor.Core/Services/BusinessHoursCalculator.cs index 9fe628b..4c3a676 100644 --- a/src/OrderMonitor.Core/Services/BusinessHoursCalculator.cs +++ b/src/OrderMonitor.Core/Services/BusinessHoursCalculator.cs @@ -1,26 +1,35 @@ +using Microsoft.Extensions.Options; +using OrderMonitor.Core.Configuration; + namespace OrderMonitor.Core.Services; /// /// Calculates business hours excluding weekends and holidays. +/// Holidays are loaded from configuration (BusinessHours:Holidays) instead of being hardcoded. /// public class BusinessHoursCalculator { private readonly HashSet _holidays; /// - /// Initializes the calculator with a list of holiday dates. + /// Initializes the calculator with holidays from configuration. + /// + public BusinessHoursCalculator(IOptions settings) + : this(settings.Value.GetHolidayDates()) + { + } + + /// + /// Initializes the calculator with an explicit list of holiday dates. /// public BusinessHoursCalculator(IEnumerable? holidays = null) { - _holidays = holidays?.Select(h => h.Date).ToHashSet() ?? GetDefaultHolidays(); + _holidays = holidays?.Select(h => h.Date).ToHashSet() ?? new HashSet(); } /// /// Calculates business hours between two dates, excluding weekends and holidays. /// - /// Start date/time - /// End date/time (defaults to UTC now) - /// Number of business hours public int CalculateBusinessHours(DateTime startDate, DateTime? endDate = null) { var end = endDate ?? DateTime.UtcNow; @@ -35,7 +44,6 @@ public int CalculateBusinessHours(DateTime startDate, DateTime? endDate = null) { if (IsBusinessDay(current)) { - // Calculate hours for this day var dayStart = current.Date; var dayEnd = dayStart.AddDays(1); @@ -48,7 +56,6 @@ public int CalculateBusinessHours(DateTime startDate, DateTime? endDate = null) } } - // Move to start of next day current = current.Date.AddDays(1); } @@ -60,11 +67,9 @@ public int CalculateBusinessHours(DateTime startDate, DateTime? endDate = null) /// public bool IsBusinessDay(DateTime date) { - // Check weekend if (date.DayOfWeek == DayOfWeek.Saturday || date.DayOfWeek == DayOfWeek.Sunday) return false; - // Check holiday if (_holidays.Contains(date.Date)) return false; @@ -100,43 +105,4 @@ public int GetHolidayDays(DateTime startDate, DateTime endDate) h.DayOfWeek != DayOfWeek.Saturday && h.DayOfWeek != DayOfWeek.Sunday); } - - /// - /// Default holidays for 2025-2026 (UK/EU focused for Printerpix). - /// - private static HashSet GetDefaultHolidays() - { - return new HashSet - { - // 2025 Holidays - new DateTime(2025, 1, 1), // New Year's Day - new DateTime(2025, 4, 18), // Good Friday - new DateTime(2025, 4, 21), // Easter Monday - new DateTime(2025, 5, 5), // Early May Bank Holiday - new DateTime(2025, 5, 26), // Spring Bank Holiday - new DateTime(2025, 8, 25), // Summer Bank Holiday - new DateTime(2025, 12, 25), // Christmas Day - new DateTime(2025, 12, 26), // Boxing Day - - // 2026 Holidays - new DateTime(2026, 1, 1), // New Year's Day - new DateTime(2026, 4, 3), // Good Friday - new DateTime(2026, 4, 6), // Easter Monday - new DateTime(2026, 5, 4), // Early May Bank Holiday - new DateTime(2026, 5, 25), // Spring Bank Holiday - new DateTime(2026, 8, 31), // Summer Bank Holiday - new DateTime(2026, 12, 25), // Christmas Day - new DateTime(2026, 12, 28), // Boxing Day (observed) - - // 2027 Holidays - new DateTime(2027, 1, 1), // New Year's Day - new DateTime(2027, 3, 26), // Good Friday - new DateTime(2027, 3, 29), // Easter Monday - new DateTime(2027, 5, 3), // Early May Bank Holiday - new DateTime(2027, 5, 31), // Spring Bank Holiday - new DateTime(2027, 8, 30), // Summer Bank Holiday - new DateTime(2027, 12, 27), // Christmas Day (observed) - new DateTime(2027, 12, 28), // Boxing Day (observed) - }; - } } diff --git a/src/OrderMonitor.Infrastructure/Configuration/ConfigurationValidator.cs b/src/OrderMonitor.Infrastructure/Configuration/ConfigurationValidator.cs new file mode 100644 index 0000000..8a7b671 --- /dev/null +++ b/src/OrderMonitor.Infrastructure/Configuration/ConfigurationValidator.cs @@ -0,0 +1,74 @@ +using Microsoft.Extensions.Configuration; +using OrderMonitor.Core.Interfaces; + +namespace OrderMonitor.Infrastructure.Configuration; + +/// +/// Validates that all required configuration values are present and valid at startup. +/// +public class ConfigurationValidator : IConfigurationValidator +{ + private static readonly string[] ValidDatabaseProviders = { "sqlserver", "mysql", "postgresql" }; + + private readonly IConfiguration _configuration; + + public ConfigurationValidator(IConfiguration configuration) + { + _configuration = configuration; + } + + public void Validate() + { + var errors = new List(); + + // Database validation + var provider = _configuration["Database:Provider"]; + if (string.IsNullOrWhiteSpace(provider)) + { + errors.Add("Database:Provider is required. Allowed values: sqlserver, mysql, postgresql"); + } + else if (!ValidDatabaseProviders.Contains(provider, StringComparer.OrdinalIgnoreCase)) + { + errors.Add($"Database:Provider '{provider}' is invalid. Allowed values: {string.Join(", ", ValidDatabaseProviders)}"); + } + + var connectionString = _configuration["Database:ConnectionString"]; + if (string.IsNullOrWhiteSpace(connectionString)) + { + // Fall back to legacy ConnectionStrings:BackofficeDb + connectionString = _configuration.GetConnectionString("BackofficeDb"); + if (string.IsNullOrWhiteSpace(connectionString)) + { + errors.Add("Database:ConnectionString (or ConnectionStrings:BackofficeDb) is required."); + } + } + + // SMTP validation + var smtpHost = _configuration["SmtpSettings:Host"]; + if (string.IsNullOrWhiteSpace(smtpHost)) + { + errors.Add("SmtpSettings:Host is required."); + } + + // Alerts validation + var alertRecipients = _configuration["Alerts:Recipients"]; + var alertEnabled = _configuration["Alerts:Enabled"]; + if (string.Equals(alertEnabled, "true", StringComparison.OrdinalIgnoreCase) + && string.IsNullOrWhiteSpace(alertRecipients)) + { + // Check for array-style binding (Alerts:Recipients:0, Alerts:Recipients:1, etc.) + var section = _configuration.GetSection("Alerts:Recipients"); + if (!section.GetChildren().Any()) + { + errors.Add("Alerts:Recipients is required when Alerts:Enabled is true."); + } + } + + if (errors.Count > 0) + { + throw new InvalidOperationException( + "Configuration validation failed:\n" + + string.Join("\n", errors.Select(e => $" - {e}"))); + } + } +} diff --git a/src/OrderMonitor.Infrastructure/Configuration/YamlConfigLoader.cs b/src/OrderMonitor.Infrastructure/Configuration/YamlConfigLoader.cs new file mode 100644 index 0000000..6ddf67b --- /dev/null +++ b/src/OrderMonitor.Infrastructure/Configuration/YamlConfigLoader.cs @@ -0,0 +1,50 @@ +using OrderMonitor.Core.Interfaces; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace OrderMonitor.Infrastructure.Configuration; + +/// +/// Loads configuration from a YAML file and returns a flat key-value dictionary. +/// Double-underscore (__) in YAML keys maps to colon (:) for ASP.NET Core configuration. +/// +public class YamlConfigLoader : IYamlConfigLoader +{ + public IDictionary Load(string filePath) + { + if (!File.Exists(filePath)) + throw new FileNotFoundException($"Configuration file not found: {filePath}", filePath); + + var content = File.ReadAllText(filePath); + + if (string.IsNullOrWhiteSpace(content)) + return new Dictionary(); + + var deserializer = new DeserializerBuilder() + .WithNamingConvention(NullNamingConvention.Instance) + .Build(); + + Dictionary? yamlData; + try + { + yamlData = deserializer.Deserialize>(content); + } + catch (Exception ex) + { + throw new FormatException($"Invalid YAML format in {filePath}: {ex.Message}", ex); + } + + if (yamlData == null) + return new Dictionary(); + + // Map double-underscore to colon for ASP.NET Core config hierarchy + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kvp in yamlData) + { + var configKey = kvp.Key.Replace("__", ":"); + result[configKey] = kvp.Value ?? string.Empty; + } + + return result; + } +} diff --git a/src/OrderMonitor.Infrastructure/Configuration/YamlConfigurationSource.cs b/src/OrderMonitor.Infrastructure/Configuration/YamlConfigurationSource.cs new file mode 100644 index 0000000..1d008d7 --- /dev/null +++ b/src/OrderMonitor.Infrastructure/Configuration/YamlConfigurationSource.cs @@ -0,0 +1,75 @@ +using Microsoft.Extensions.Configuration; + +namespace OrderMonitor.Infrastructure.Configuration; + +/// +/// ASP.NET Core configuration source that loads values from a YAML file. +/// +public class YamlConfigurationSource : IConfigurationSource +{ + public string FilePath { get; } + public bool Optional { get; } + + public YamlConfigurationSource(string filePath, bool optional = false) + { + FilePath = filePath; + Optional = optional; + } + + public IConfigurationProvider Build(IConfigurationBuilder builder) + { + return new YamlConfigurationProvider(this); + } +} + +/// +/// Configuration provider that reads values from a YAML file. +/// +public class YamlConfigurationProvider : ConfigurationProvider +{ + private readonly YamlConfigurationSource _source; + + public YamlConfigurationProvider(YamlConfigurationSource source) + { + _source = source; + } + + public override void Load() + { + if (!File.Exists(_source.FilePath)) + { + if (_source.Optional) + { + Data = new Dictionary(StringComparer.OrdinalIgnoreCase); + return; + } + throw new FileNotFoundException($"Configuration file not found: {_source.FilePath}", _source.FilePath); + } + + var loader = new YamlConfigLoader(); + var values = loader.Load(_source.FilePath); + + Data = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kvp in values) + { + Data[kvp.Key] = kvp.Value; + } + } +} + +/// +/// Extension methods for adding YAML configuration to the configuration builder. +/// +public static class YamlConfigurationExtensions +{ + /// + /// Adds a YAML file as a configuration source. + /// + public static IConfigurationBuilder AddYamlFile( + this IConfigurationBuilder builder, + string path, + bool optional = false) + { + return builder.Add(new YamlConfigurationSource(path, optional)); + } +} diff --git a/src/OrderMonitor.Infrastructure/DependencyInjection.cs b/src/OrderMonitor.Infrastructure/DependencyInjection.cs index cd5261c..6b4d953 100644 --- a/src/OrderMonitor.Infrastructure/DependencyInjection.cs +++ b/src/OrderMonitor.Infrastructure/DependencyInjection.cs @@ -3,6 +3,7 @@ using OrderMonitor.Core.Configuration; using OrderMonitor.Core.Interfaces; using OrderMonitor.Core.Services; +using OrderMonitor.Infrastructure.Configuration; using OrderMonitor.Infrastructure.Data; using OrderMonitor.Infrastructure.Security; using OrderMonitor.Infrastructure.Services; @@ -21,22 +22,44 @@ public static IServiceCollection AddInfrastructure( this IServiceCollection services, IConfiguration configuration) { - // Register database connection factory - var connectionString = configuration.GetConnectionString("BackofficeDb") - ?? throw new InvalidOperationException("BackofficeDb connection string is not configured."); - - // Check if password is encrypted (contains placeholder) - if (connectionString.Contains("{ENCRYPTED}")) + // Use factory pattern to defer connection string resolution until first use. + // This ensures all configuration sources (YAML, env vars, test overrides) are loaded. + services.AddSingleton(sp => { - var encryptedPassword = configuration["DatabaseSettings:EncryptedPassword"]; - if (!string.IsNullOrEmpty(encryptedPassword)) + var config = sp.GetRequiredService(); + + // Configure encryption key from Database settings + var encryptionKey = config["Database:EncryptionKey"]; + if (!string.IsNullOrEmpty(encryptionKey)) + { + PasswordEncryptor.Configure(encryptionKey); + } + + // Resolve connection string: prefer new Database:ConnectionString, fall back to legacy + var connectionString = config["Database:ConnectionString"]; + if (string.IsNullOrWhiteSpace(connectionString)) { - var decryptedPassword = PasswordEncryptor.Decrypt(encryptedPassword); - connectionString = connectionString.Replace("{ENCRYPTED}", decryptedPassword); + connectionString = config.GetConnectionString("BackofficeDb") + ?? throw new InvalidOperationException( + "Database connection string is not configured. " + + "Set Database__ConnectionString or ConnectionStrings__BackofficeDb."); } - } - services.AddSingleton(sp => new SqlConnectionFactory(connectionString)); + // Decrypt password if needed + if (connectionString.Contains("{ENCRYPTED}")) + { + var encryptedPassword = config["Database:EncryptedPassword"] + ?? config["DatabaseSettings:EncryptedPassword"]; + + if (!string.IsNullOrEmpty(encryptedPassword)) + { + var decryptedPassword = PasswordEncryptor.Decrypt(encryptedPassword); + connectionString = connectionString.Replace("{ENCRYPTED}", decryptedPassword); + } + } + + return new SqlConnectionFactory(connectionString); + }); // Register repositories services.AddScoped(); @@ -45,11 +68,22 @@ public static IServiceCollection AddInfrastructure( services.AddScoped(); services.AddScoped(); - // Register configuration settings + // Register configuration settings (all sections) + services.Configure(configuration.GetSection(DatabaseSettings.SectionName)); + services.Configure(configuration.GetSection(BusinessHoursSettings.SectionName)); + services.Configure(configuration.GetSection(HealthCheckSettings.SectionName)); + services.Configure(configuration.GetSection(SwaggerSettings.SectionName)); services.Configure(configuration.GetSection(ScannerSettings.SectionName)); services.Configure(configuration.GetSection(SmtpSettings.SectionName)); services.Configure(configuration.GetSection(AlertSettings.SectionName)); + // Register configuration validator + services.AddSingleton(sp => + new ConfigurationValidator(configuration)); + + // Register YAML config loader + services.AddSingleton(); + // Register background scanner as hosted service services.AddHostedService(); diff --git a/src/OrderMonitor.Infrastructure/OrderMonitor.Infrastructure.csproj b/src/OrderMonitor.Infrastructure/OrderMonitor.Infrastructure.csproj index ed9fc01..c0297f4 100644 --- a/src/OrderMonitor.Infrastructure/OrderMonitor.Infrastructure.csproj +++ b/src/OrderMonitor.Infrastructure/OrderMonitor.Infrastructure.csproj @@ -9,6 +9,7 @@ + @@ -17,4 +18,9 @@ enable + + + + + diff --git a/src/OrderMonitor.Infrastructure/Security/PasswordEncryptor.cs b/src/OrderMonitor.Infrastructure/Security/PasswordEncryptor.cs index 6395865..9dd8eea 100644 --- a/src/OrderMonitor.Infrastructure/Security/PasswordEncryptor.cs +++ b/src/OrderMonitor.Infrastructure/Security/PasswordEncryptor.cs @@ -5,24 +5,42 @@ namespace OrderMonitor.Infrastructure.Security; /// /// Utility for encrypting and decrypting passwords using AES encryption. +/// The encryption key must be provided via configuration (Database:EncryptionKey) +/// and is never hardcoded. /// public static class PasswordEncryptor { - // Default key - in production, use a key from environment variable or secure storage - private static readonly string DefaultKey = "OrderMonitor2026SecureKey32Bytes!"; + private static string? _configuredKey; + + /// + /// Sets the encryption key from application configuration. + /// Must be called during startup before any Encrypt/Decrypt operations. + /// + public static void Configure(string encryptionKey) + { + if (string.IsNullOrWhiteSpace(encryptionKey)) + throw new ArgumentException("Encryption key cannot be null or empty.", nameof(encryptionKey)); + + _configuredKey = encryptionKey; + } /// /// Encrypts a plain text password. /// /// The plain text password to encrypt. - /// Optional encryption key (32 characters). If not provided, uses default key. + /// Optional encryption key. If not provided, uses the configured key. /// Base64 encoded encrypted string with IV prepended. public static string Encrypt(string plainText, string? key = null) { if (string.IsNullOrEmpty(plainText)) return plainText; - var encryptionKey = GetKeyBytes(key ?? DefaultKey); + var effectiveKey = key ?? _configuredKey + ?? throw new InvalidOperationException( + "Encryption key is not configured. Set Database__EncryptionKey environment variable " + + "or call PasswordEncryptor.Configure() during startup."); + + var encryptionKey = GetKeyBytes(effectiveKey); using var aes = Aes.Create(); aes.Key = encryptionKey; @@ -44,44 +62,39 @@ public static string Encrypt(string plainText, string? key = null) /// Decrypts an encrypted password. /// /// The Base64 encoded encrypted string. - /// Optional encryption key (32 characters). If not provided, uses default key. + /// Optional encryption key. If not provided, uses the configured key. /// The decrypted plain text password. public static string Decrypt(string encryptedText, string? key = null) { if (string.IsNullOrEmpty(encryptedText)) return encryptedText; - // Check if it looks like an encrypted value (Base64 with reasonable length) if (!IsEncrypted(encryptedText)) - return encryptedText; // Return as-is if not encrypted + return encryptedText; - try - { - var encryptionKey = GetKeyBytes(key ?? DefaultKey); - var fullCipher = Convert.FromBase64String(encryptedText); + var effectiveKey = key ?? _configuredKey + ?? throw new InvalidOperationException( + "Encryption key is not configured. Set Database__EncryptionKey environment variable " + + "or call PasswordEncryptor.Configure() during startup."); - using var aes = Aes.Create(); - aes.Key = encryptionKey; + var encryptionKey = GetKeyBytes(effectiveKey); + var fullCipher = Convert.FromBase64String(encryptedText); - // Extract IV from the beginning - var iv = new byte[aes.BlockSize / 8]; - var cipherBytes = new byte[fullCipher.Length - iv.Length]; + using var aes = Aes.Create(); + aes.Key = encryptionKey; - Buffer.BlockCopy(fullCipher, 0, iv, 0, iv.Length); - Buffer.BlockCopy(fullCipher, iv.Length, cipherBytes, 0, cipherBytes.Length); + var iv = new byte[aes.BlockSize / 8]; + var cipherBytes = new byte[fullCipher.Length - iv.Length]; - aes.IV = iv; + Buffer.BlockCopy(fullCipher, 0, iv, 0, iv.Length); + Buffer.BlockCopy(fullCipher, iv.Length, cipherBytes, 0, cipherBytes.Length); - using var decryptor = aes.CreateDecryptor(aes.Key, aes.IV); - var plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length); + aes.IV = iv; - return Encoding.UTF8.GetString(plainBytes); - } - catch - { - // If decryption fails, return original (might be plain text) - return encryptedText; - } + using var decryptor = aes.CreateDecryptor(aes.Key, aes.IV); + var plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length); + + return Encoding.UTF8.GetString(plainBytes); } /// @@ -95,7 +108,7 @@ public static bool IsEncrypted(string value) try { var bytes = Convert.FromBase64String(value); - return bytes.Length >= 32; // At least IV (16) + some encrypted data + return bytes.Length >= 32; } catch { @@ -103,9 +116,16 @@ public static bool IsEncrypted(string value) } } + /// + /// Resets the configured key. Used for testing only. + /// + internal static void Reset() + { + _configuredKey = null; + } + private static byte[] GetKeyBytes(string key) { - // Ensure key is exactly 32 bytes (256 bits) for AES-256 var keyBytes = Encoding.UTF8.GetBytes(key); var result = new byte[32]; @@ -116,7 +136,6 @@ private static byte[] GetKeyBytes(string key) else { Buffer.BlockCopy(keyBytes, 0, result, 0, keyBytes.Length); - // Pad with derived bytes using var sha = SHA256.Create(); var hash = sha.ComputeHash(keyBytes); Buffer.BlockCopy(hash, 0, result, keyBytes.Length, 32 - keyBytes.Length); diff --git a/tests/OrderMonitor.IntegrationTests/ConfigurationIntegrationTests.cs b/tests/OrderMonitor.IntegrationTests/ConfigurationIntegrationTests.cs new file mode 100644 index 0000000..5ec63b9 --- /dev/null +++ b/tests/OrderMonitor.IntegrationTests/ConfigurationIntegrationTests.cs @@ -0,0 +1,67 @@ +using System.Net; +using FluentAssertions; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using OrderMonitor.Core.Interfaces; + +namespace OrderMonitor.IntegrationTests; + +/// +/// Integration tests for the YAML configuration pipeline. +/// Validates that configuration loads correctly and overrides work as expected. +/// +public class ConfigurationIntegrationTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public ConfigurationIntegrationTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task App_StartsSuccessfully_WithValidConfiguration() + { + // Arrange & Act + _factory.SetupDefaultMocks(); + using var client = _factory.CreateClient(); + var response = await client.GetAsync("/health"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task App_ConfigurationOverride_InMemoryOverridesYaml() + { + // The factory adds in-memory config with Database:Provider = sqlserver + // This tests that it's correctly available + _factory.SetupDefaultMocks(); + using var client = _factory.CreateClient(); + + // If the app starts, the config was loaded correctly + var response = await client.GetAsync("/health"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public void ConfigurationValidator_ThrowsOnMissingRequired() + { + // Directly test the validator with incomplete configuration + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Logging:LogLevel:Default"] = "Warning" + }) + .Build(); + + var validator = new OrderMonitor.Infrastructure.Configuration.ConfigurationValidator(config); + + var exception = Assert.Throws(() => validator.Validate()); + exception.Message.Should().Contain("Configuration validation failed"); + exception.Message.Should().Contain("Database:Provider is required"); + } +} diff --git a/tests/OrderMonitor.IntegrationTests/CustomWebApplicationFactory.cs b/tests/OrderMonitor.IntegrationTests/CustomWebApplicationFactory.cs index ff51cb7..bfb3cba 100644 --- a/tests/OrderMonitor.IntegrationTests/CustomWebApplicationFactory.cs +++ b/tests/OrderMonitor.IntegrationTests/CustomWebApplicationFactory.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Moq; @@ -11,6 +12,7 @@ namespace OrderMonitor.IntegrationTests; /// /// Custom WebApplicationFactory for integration testing. /// Replaces real services with mocks for isolated testing. +/// Provides minimal required configuration to satisfy startup validation. /// public class CustomWebApplicationFactory : WebApplicationFactory { @@ -21,12 +23,30 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("Testing"); + // Provide minimal configuration required by ConfigurationValidator + builder.ConfigureAppConfiguration((context, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["Database:Provider"] = "sqlserver", + ["Database:ConnectionString"] = "Server=localhost;Database=TestDb;Trusted_Connection=True;", + ["SmtpSettings:Host"] = "localhost", + ["SmtpSettings:Port"] = "25", + ["Alerts:Enabled"] = "false" + }); + }); + builder.ConfigureServices(services => { // Remove real service registrations services.RemoveAll(); services.RemoveAll(); + // Replace config validator with no-op for testing + services.RemoveAll(); + services.AddSingleton(sp => + new NoOpConfigurationValidator()); + // Add mocked services services.AddSingleton(MockStuckOrderService.Object); services.AddSingleton(MockAlertService.Object); @@ -105,3 +125,11 @@ public void SetupDefaultMocks() .Returns(Task.CompletedTask); } } + +/// +/// No-op configuration validator for integration testing. +/// +internal class NoOpConfigurationValidator : IConfigurationValidator +{ + public void Validate() { } +} diff --git a/tests/OrderMonitor.UnitTests/Configuration/BusinessHoursSettingsTests.cs b/tests/OrderMonitor.UnitTests/Configuration/BusinessHoursSettingsTests.cs new file mode 100644 index 0000000..d61f34a --- /dev/null +++ b/tests/OrderMonitor.UnitTests/Configuration/BusinessHoursSettingsTests.cs @@ -0,0 +1,113 @@ +using OrderMonitor.Core.Configuration; +using Xunit; + +namespace OrderMonitor.UnitTests.Configuration; + +public class BusinessHoursSettingsTests +{ + [Fact] + public void GetHolidayDates_EmptyString_ReturnsEmpty() + { + var settings = new BusinessHoursSettings { Holidays = "" }; + + var dates = settings.GetHolidayDates().ToList(); + + Assert.Empty(dates); + } + + [Fact] + public void GetHolidayDates_NullString_ReturnsEmpty() + { + var settings = new BusinessHoursSettings { Holidays = null! }; + + var dates = settings.GetHolidayDates().ToList(); + + Assert.Empty(dates); + } + + [Fact] + public void GetHolidayDates_WhitespaceString_ReturnsEmpty() + { + var settings = new BusinessHoursSettings { Holidays = " " }; + + var dates = settings.GetHolidayDates().ToList(); + + Assert.Empty(dates); + } + + [Fact] + public void GetHolidayDates_SingleDate_ReturnsSingleDate() + { + var settings = new BusinessHoursSettings { Holidays = "2026-12-25" }; + + var dates = settings.GetHolidayDates().ToList(); + + Assert.Single(dates); + Assert.Equal(new DateTime(2026, 12, 25), dates[0]); + } + + [Fact] + public void GetHolidayDates_MultipleDates_ReturnsAllDates() + { + var settings = new BusinessHoursSettings + { + Holidays = "2026-01-01,2026-12-25,2026-12-26" + }; + + var dates = settings.GetHolidayDates().ToList(); + + Assert.Equal(3, dates.Count); + Assert.Contains(new DateTime(2026, 1, 1), dates); + Assert.Contains(new DateTime(2026, 12, 25), dates); + Assert.Contains(new DateTime(2026, 12, 26), dates); + } + + [Fact] + public void GetHolidayDates_WithSpaces_TrimsCorrectly() + { + var settings = new BusinessHoursSettings + { + Holidays = " 2026-01-01 , 2026-12-25 " + }; + + var dates = settings.GetHolidayDates().ToList(); + + Assert.Equal(2, dates.Count); + } + + [Fact] + public void GetHolidayDates_InvalidDate_SkipsInvalid() + { + var settings = new BusinessHoursSettings + { + Holidays = "2026-01-01,not-a-date,2026-12-25" + }; + + var dates = settings.GetHolidayDates().ToList(); + + Assert.Equal(2, dates.Count); + Assert.Contains(new DateTime(2026, 1, 1), dates); + Assert.Contains(new DateTime(2026, 12, 25), dates); + } + + [Fact] + public void GetHolidayDates_ReturnsDateOnly_NoTime() + { + var settings = new BusinessHoursSettings { Holidays = "2026-12-25" }; + + var dates = settings.GetHolidayDates().ToList(); + + Assert.Equal(TimeSpan.Zero, dates[0].TimeOfDay); + } + + [Fact] + public void Defaults_AreCorrect() + { + var settings = new BusinessHoursSettings(); + + Assert.Equal("Europe/London", settings.Timezone); + Assert.Equal(0, settings.StartHour); + Assert.Equal(0, settings.EndHour); + Assert.Equal(string.Empty, settings.Holidays); + } +} diff --git a/tests/OrderMonitor.UnitTests/Configuration/ConfigurationValidatorTests.cs b/tests/OrderMonitor.UnitTests/Configuration/ConfigurationValidatorTests.cs new file mode 100644 index 0000000..e6fc3fb --- /dev/null +++ b/tests/OrderMonitor.UnitTests/Configuration/ConfigurationValidatorTests.cs @@ -0,0 +1,179 @@ +using Microsoft.Extensions.Configuration; +using OrderMonitor.Infrastructure.Configuration; +using Xunit; + +namespace OrderMonitor.UnitTests.Configuration; + +public class ConfigurationValidatorTests +{ + private ConfigurationValidator CreateValidator(Dictionary configValues) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configValues) + .Build(); + return new ConfigurationValidator(configuration); + } + + [Fact] + public void Validate_AllRequiredValues_DoesNotThrow() + { + var validator = CreateValidator(new Dictionary + { + ["Database:Provider"] = "sqlserver", + ["Database:ConnectionString"] = "Server=localhost;Database=TestDb;", + ["SmtpSettings:Host"] = "smtp.example.com", + ["Alerts:Enabled"] = "false" + }); + + var exception = Record.Exception(() => validator.Validate()); + + Assert.Null(exception); + } + + [Fact] + public void Validate_MissingProvider_ThrowsWithMessage() + { + var validator = CreateValidator(new Dictionary + { + ["Database:ConnectionString"] = "Server=localhost;", + ["SmtpSettings:Host"] = "smtp.example.com" + }); + + var ex = Assert.Throws(() => validator.Validate()); + Assert.Contains("Database:Provider is required", ex.Message); + } + + [Fact] + public void Validate_InvalidProvider_ThrowsWithMessage() + { + var validator = CreateValidator(new Dictionary + { + ["Database:Provider"] = "oracle", + ["Database:ConnectionString"] = "Server=localhost;", + ["SmtpSettings:Host"] = "smtp.example.com" + }); + + var ex = Assert.Throws(() => validator.Validate()); + Assert.Contains("'oracle' is invalid", ex.Message); + } + + [Theory] + [InlineData("sqlserver")] + [InlineData("mysql")] + [InlineData("postgresql")] + [InlineData("SqlServer")] + [InlineData("POSTGRESQL")] + public void Validate_ValidProviders_DoNotThrow(string provider) + { + var validator = CreateValidator(new Dictionary + { + ["Database:Provider"] = provider, + ["Database:ConnectionString"] = "Server=localhost;", + ["SmtpSettings:Host"] = "smtp.example.com" + }); + + var exception = Record.Exception(() => validator.Validate()); + + Assert.Null(exception); + } + + [Fact] + public void Validate_MissingConnectionString_ThrowsWithMessage() + { + var validator = CreateValidator(new Dictionary + { + ["Database:Provider"] = "sqlserver", + ["SmtpSettings:Host"] = "smtp.example.com" + }); + + var ex = Assert.Throws(() => validator.Validate()); + Assert.Contains("Database:ConnectionString", ex.Message); + } + + [Fact] + public void Validate_LegacyConnectionString_DoesNotThrow() + { + var validator = CreateValidator(new Dictionary + { + ["Database:Provider"] = "sqlserver", + ["ConnectionStrings:BackofficeDb"] = "Server=localhost;Database=BackofficeDb;", + ["SmtpSettings:Host"] = "smtp.example.com" + }); + + var exception = Record.Exception(() => validator.Validate()); + + Assert.Null(exception); + } + + [Fact] + public void Validate_MissingSmtpHost_ThrowsWithMessage() + { + var validator = CreateValidator(new Dictionary + { + ["Database:Provider"] = "sqlserver", + ["Database:ConnectionString"] = "Server=localhost;" + }); + + var ex = Assert.Throws(() => validator.Validate()); + Assert.Contains("SmtpSettings:Host is required", ex.Message); + } + + [Fact] + public void Validate_AlertsEnabledWithoutRecipients_ThrowsWithMessage() + { + var validator = CreateValidator(new Dictionary + { + ["Database:Provider"] = "sqlserver", + ["Database:ConnectionString"] = "Server=localhost;", + ["SmtpSettings:Host"] = "smtp.example.com", + ["Alerts:Enabled"] = "true" + }); + + var ex = Assert.Throws(() => validator.Validate()); + Assert.Contains("Alerts:Recipients is required when Alerts:Enabled is true", ex.Message); + } + + [Fact] + public void Validate_AlertsEnabledWithRecipients_DoesNotThrow() + { + var validator = CreateValidator(new Dictionary + { + ["Database:Provider"] = "sqlserver", + ["Database:ConnectionString"] = "Server=localhost;", + ["SmtpSettings:Host"] = "smtp.example.com", + ["Alerts:Enabled"] = "true", + ["Alerts:Recipients"] = "admin@example.com" + }); + + var exception = Record.Exception(() => validator.Validate()); + + Assert.Null(exception); + } + + [Fact] + public void Validate_AlertsDisabled_RecipientsNotRequired() + { + var validator = CreateValidator(new Dictionary + { + ["Database:Provider"] = "sqlserver", + ["Database:ConnectionString"] = "Server=localhost;", + ["SmtpSettings:Host"] = "smtp.example.com", + ["Alerts:Enabled"] = "false" + }); + + var exception = Record.Exception(() => validator.Validate()); + + Assert.Null(exception); + } + + [Fact] + public void Validate_MultipleErrors_AllReported() + { + var validator = CreateValidator(new Dictionary()); + + var ex = Assert.Throws(() => validator.Validate()); + Assert.Contains("Database:Provider is required", ex.Message); + Assert.Contains("Database:ConnectionString", ex.Message); + Assert.Contains("SmtpSettings:Host is required", ex.Message); + } +} diff --git a/tests/OrderMonitor.UnitTests/Configuration/YamlConfigLoaderTests.cs b/tests/OrderMonitor.UnitTests/Configuration/YamlConfigLoaderTests.cs new file mode 100644 index 0000000..af7741f --- /dev/null +++ b/tests/OrderMonitor.UnitTests/Configuration/YamlConfigLoaderTests.cs @@ -0,0 +1,144 @@ +using OrderMonitor.Infrastructure.Configuration; +using Xunit; + +namespace OrderMonitor.UnitTests.Configuration; + +public class YamlConfigLoaderTests : IDisposable +{ + private readonly string _tempDir; + private readonly YamlConfigLoader _loader; + + public YamlConfigLoaderTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"yaml_test_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + _loader = new YamlConfigLoader(); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); + } + + private string CreateTempYaml(string content) + { + var path = Path.Combine(_tempDir, $"test_{Guid.NewGuid():N}.yml"); + File.WriteAllText(path, content); + return path; + } + + [Fact] + public void Load_ValidYaml_ReturnsFlatDictionary() + { + var path = CreateTempYaml("Key1: Value1\nKey2: Value2"); + + var result = _loader.Load(path); + + Assert.Equal("Value1", result["Key1"]); + Assert.Equal("Value2", result["Key2"]); + } + + [Fact] + public void Load_DoubleUnderscore_MapsToColon() + { + var path = CreateTempYaml("Database__ConnectionString: \"Server=localhost\""); + + var result = _loader.Load(path); + + Assert.True(result.ContainsKey("Database:ConnectionString")); + Assert.Equal("Server=localhost", result["Database:ConnectionString"]); + } + + [Fact] + public void Load_MultipleNestedKeys_AllMapped() + { + var yaml = @"Database__Provider: sqlserver +Database__ConnectionString: ""Server=localhost"" +SmtpSettings__Host: smtp.example.com +SmtpSettings__Port: ""587"" +Alerts__Enabled: ""true"" +Alerts__Recipients: admin@example.com"; + + var path = CreateTempYaml(yaml); + var result = _loader.Load(path); + + Assert.Equal("sqlserver", result["Database:Provider"]); + Assert.Equal("Server=localhost", result["Database:ConnectionString"]); + Assert.Equal("smtp.example.com", result["SmtpSettings:Host"]); + Assert.Equal("587", result["SmtpSettings:Port"]); + Assert.Equal("true", result["Alerts:Enabled"]); + Assert.Equal("admin@example.com", result["Alerts:Recipients"]); + } + + [Fact] + public void Load_FileNotFound_ThrowsFileNotFoundException() + { + var nonExistentPath = Path.Combine(_tempDir, "nonexistent.yml"); + + Assert.Throws(() => _loader.Load(nonExistentPath)); + } + + [Fact] + public void Load_EmptyFile_ReturnsEmptyDictionary() + { + var path = CreateTempYaml(""); + + var result = _loader.Load(path); + + Assert.Empty(result); + } + + [Fact] + public void Load_WhitespaceOnlyFile_ReturnsEmptyDictionary() + { + var path = CreateTempYaml(" \n \n "); + + var result = _loader.Load(path); + + Assert.Empty(result); + } + + [Fact] + public void Load_InvalidYaml_ThrowsFormatException() + { + var path = CreateTempYaml("{ invalid yaml [[["); + + Assert.Throws(() => _loader.Load(path)); + } + + [Fact] + public void Load_KeysAreCaseInsensitive() + { + var path = CreateTempYaml("Database__Provider: sqlserver"); + + var result = _loader.Load(path); + + Assert.True(result.ContainsKey("database:provider")); + Assert.True(result.ContainsKey("DATABASE:PROVIDER")); + } + + [Fact] + public void Load_NullValues_ConvertedToEmptyString() + { + var path = CreateTempYaml("EmptyKey: "); + + var result = _loader.Load(path); + + Assert.True(result.ContainsKey("EmptyKey")); + Assert.Equal("", result["EmptyKey"]); + } + + [Fact] + public void Load_CommentsIgnored() + { + var yaml = "# This is a comment\nKey1: Value1\n# Another comment\nKey2: Value2"; + var path = CreateTempYaml(yaml); + + var result = _loader.Load(path); + + Assert.Equal(2, result.Count); + Assert.Equal("Value1", result["Key1"]); + Assert.Equal("Value2", result["Key2"]); + } +} diff --git a/tests/OrderMonitor.UnitTests/Configuration/YamlConfigurationSourceTests.cs b/tests/OrderMonitor.UnitTests/Configuration/YamlConfigurationSourceTests.cs new file mode 100644 index 0000000..ba48a0d --- /dev/null +++ b/tests/OrderMonitor.UnitTests/Configuration/YamlConfigurationSourceTests.cs @@ -0,0 +1,116 @@ +using Microsoft.Extensions.Configuration; +using OrderMonitor.Infrastructure.Configuration; +using Xunit; + +namespace OrderMonitor.UnitTests.Configuration; + +public class YamlConfigurationSourceTests : IDisposable +{ + private readonly string _tempDir; + + public YamlConfigurationSourceTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"yaml_cfg_test_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); + } + + private string CreateTempYaml(string content) + { + var path = Path.Combine(_tempDir, $"test_{Guid.NewGuid():N}.yml"); + File.WriteAllText(path, content); + return path; + } + + [Fact] + public void AddYamlFile_LoadsIntoConfiguration() + { + var path = CreateTempYaml("Database__Provider: sqlserver\nDatabase__ConnectionString: \"Server=test\""); + + var config = new ConfigurationBuilder() + .AddYamlFile(path) + .Build(); + + Assert.Equal("sqlserver", config["Database:Provider"]); + Assert.Equal("Server=test", config["Database:ConnectionString"]); + } + + [Fact] + public void AddYamlFile_OptionalMissing_DoesNotThrow() + { + var missingPath = Path.Combine(_tempDir, "nonexistent.yml"); + + var config = new ConfigurationBuilder() + .AddYamlFile(missingPath, optional: true) + .Build(); + + Assert.Null(config["AnyKey"]); + } + + [Fact] + public void AddYamlFile_RequiredMissing_ThrowsFileNotFound() + { + var missingPath = Path.Combine(_tempDir, "nonexistent.yml"); + + var builder = new ConfigurationBuilder() + .AddYamlFile(missingPath, optional: false); + + Assert.Throws(() => builder.Build()); + } + + [Fact] + public void AddYamlFile_OverridesPreviousValues() + { + var base_yaml = CreateTempYaml("Database__Provider: sqlserver"); + var override_yaml = CreateTempYaml("Database__Provider: postgresql"); + + var config = new ConfigurationBuilder() + .AddYamlFile(base_yaml) + .AddYamlFile(override_yaml) + .Build(); + + Assert.Equal("postgresql", config["Database:Provider"]); + } + + [Fact] + public void AddYamlFile_EnvironmentVariablesOverrideYaml() + { + var path = CreateTempYaml("Database__Provider: sqlserver"); + + var config = new ConfigurationBuilder() + .AddYamlFile(path) + .AddInMemoryCollection(new Dictionary + { + ["Database:Provider"] = "postgresql" + }) + .Build(); + + Assert.Equal("postgresql", config["Database:Provider"]); + } + + [Fact] + public void YamlConfigurationSource_Build_ReturnsProvider() + { + var source = new YamlConfigurationSource("test.yml", optional: true); + var builder = new ConfigurationBuilder(); + + var provider = source.Build(builder); + + Assert.NotNull(provider); + Assert.IsType(provider); + } + + [Fact] + public void YamlConfigurationSource_StoresProperties() + { + var source = new YamlConfigurationSource("/path/to/file.yml", optional: true); + + Assert.Equal("/path/to/file.yml", source.FilePath); + Assert.True(source.Optional); + } +} diff --git a/tests/OrderMonitor.UnitTests/Security/PasswordEncryptorTests.cs b/tests/OrderMonitor.UnitTests/Security/PasswordEncryptorTests.cs new file mode 100644 index 0000000..7dd58cb --- /dev/null +++ b/tests/OrderMonitor.UnitTests/Security/PasswordEncryptorTests.cs @@ -0,0 +1,193 @@ +using OrderMonitor.Infrastructure.Security; +using Xunit; + +namespace OrderMonitor.UnitTests.Security; + +public class PasswordEncryptorTests : IDisposable +{ + private const string TestKey = "TestEncryptionKey32BytesLong!!!!"; // Exactly 32 chars + + public PasswordEncryptorTests() + { + // Reset state before each test + PasswordEncryptor.Reset(); + } + + public void Dispose() + { + PasswordEncryptor.Reset(); + } + + [Fact] + public void Configure_SetsEncryptionKey() + { + PasswordEncryptor.Configure(TestKey); + + // Should not throw when encrypting without explicit key + var encrypted = PasswordEncryptor.Encrypt("test"); + Assert.NotNull(encrypted); + Assert.NotEmpty(encrypted); + } + + [Fact] + public void Configure_NullKey_ThrowsArgumentException() + { + Assert.Throws(() => PasswordEncryptor.Configure(null!)); + } + + [Fact] + public void Configure_EmptyKey_ThrowsArgumentException() + { + Assert.Throws(() => PasswordEncryptor.Configure("")); + } + + [Fact] + public void Configure_WhitespaceKey_ThrowsArgumentException() + { + Assert.Throws(() => PasswordEncryptor.Configure(" ")); + } + + [Fact] + public void Encrypt_WithoutConfiguredKey_ThrowsInvalidOperationException() + { + var ex = Assert.Throws( + () => PasswordEncryptor.Encrypt("test")); + Assert.Contains("Encryption key is not configured", ex.Message); + } + + [Fact] + public void Decrypt_WithoutConfiguredKey_ThrowsInvalidOperationException() + { + // First encrypt with explicit key to get valid ciphertext + var encrypted = PasswordEncryptor.Encrypt("test", TestKey); + + PasswordEncryptor.Reset(); + + var ex = Assert.Throws( + () => PasswordEncryptor.Decrypt(encrypted)); + Assert.Contains("Encryption key is not configured", ex.Message); + } + + [Fact] + public void Encrypt_WithConfiguredKey_ProducesDecryptableResult() + { + PasswordEncryptor.Configure(TestKey); + + var plainText = "MySecretPassword123!"; + var encrypted = PasswordEncryptor.Encrypt(plainText); + var decrypted = PasswordEncryptor.Decrypt(encrypted); + + Assert.Equal(plainText, decrypted); + } + + [Fact] + public void Encrypt_WithExplicitKey_OverridesConfiguredKey() + { + PasswordEncryptor.Configure(TestKey); + var explicitKey = "AnotherKey32BytesLongForTesting!"; + + var encrypted = PasswordEncryptor.Encrypt("test", explicitKey); + var decrypted = PasswordEncryptor.Decrypt(encrypted, explicitKey); + + Assert.Equal("test", decrypted); + } + + [Fact] + public void Encrypt_EmptyString_ReturnsEmpty() + { + PasswordEncryptor.Configure(TestKey); + + Assert.Equal("", PasswordEncryptor.Encrypt("")); + } + + [Fact] + public void Encrypt_NullString_ReturnsNull() + { + PasswordEncryptor.Configure(TestKey); + + Assert.Null(PasswordEncryptor.Encrypt(null!)); + } + + [Fact] + public void Decrypt_EmptyString_ReturnsEmpty() + { + PasswordEncryptor.Configure(TestKey); + + Assert.Equal("", PasswordEncryptor.Decrypt("")); + } + + [Fact] + public void Encrypt_DifferentInputs_ProduceDifferentOutputs() + { + PasswordEncryptor.Configure(TestKey); + + var enc1 = PasswordEncryptor.Encrypt("Password1"); + var enc2 = PasswordEncryptor.Encrypt("Password2"); + + Assert.NotEqual(enc1, enc2); + } + + [Fact] + public void Encrypt_SameInput_ProducesDifferentOutputs_DueToIV() + { + PasswordEncryptor.Configure(TestKey); + + var enc1 = PasswordEncryptor.Encrypt("SamePassword"); + var enc2 = PasswordEncryptor.Encrypt("SamePassword"); + + // Each encryption generates a random IV, so outputs differ + Assert.NotEqual(enc1, enc2); + + // But both decrypt to the same value + Assert.Equal("SamePassword", PasswordEncryptor.Decrypt(enc1)); + Assert.Equal("SamePassword", PasswordEncryptor.Decrypt(enc2)); + } + + [Fact] + public void IsEncrypted_ValidBase64WithSufficientLength_ReturnsTrue() + { + PasswordEncryptor.Configure(TestKey); + var encrypted = PasswordEncryptor.Encrypt("test"); + + Assert.True(PasswordEncryptor.IsEncrypted(encrypted)); + } + + [Fact] + public void IsEncrypted_ShortString_ReturnsFalse() + { + Assert.False(PasswordEncryptor.IsEncrypted("short")); + } + + [Fact] + public void IsEncrypted_EmptyOrNull_ReturnsFalse() + { + Assert.False(PasswordEncryptor.IsEncrypted("")); + Assert.False(PasswordEncryptor.IsEncrypted(null!)); + } + + [Fact] + public void IsEncrypted_NonBase64_ReturnsFalse() + { + Assert.False(PasswordEncryptor.IsEncrypted("This is not base64 encoded!!!???")); + } + + [Fact] + public void Decrypt_NonEncryptedText_ReturnsOriginal() + { + PasswordEncryptor.Configure(TestKey); + + // Short text that IsEncrypted returns false for + var plainText = "plain"; + Assert.Equal(plainText, PasswordEncryptor.Decrypt(plainText)); + } + + [Fact] + public void Reset_ClearsConfiguredKey() + { + PasswordEncryptor.Configure(TestKey); + PasswordEncryptor.Reset(); + + Assert.Throws( + () => PasswordEncryptor.Encrypt("test")); + } +} diff --git a/tests/OrderMonitor.UnitTests/Services/BusinessHoursCalculatorTests.cs b/tests/OrderMonitor.UnitTests/Services/BusinessHoursCalculatorTests.cs index 3831651..baa4d25 100644 --- a/tests/OrderMonitor.UnitTests/Services/BusinessHoursCalculatorTests.cs +++ b/tests/OrderMonitor.UnitTests/Services/BusinessHoursCalculatorTests.cs @@ -1,3 +1,4 @@ +using OrderMonitor.Core.Configuration; using OrderMonitor.Core.Services; using Xunit; @@ -159,17 +160,39 @@ public void GetWeekendDays_TwoWeeks_ReturnsFour() } [Fact] - public void DefaultHolidays_AreLoaded() + public void DefaultConstructor_NoHolidays_AllWeekdaysAreBusinessDays() { - // Create calculator with default holidays + // Create calculator with default constructor (no hardcoded holidays) var defaultCalculator = new BusinessHoursCalculator(); - // Check that Christmas 2026 is a holiday - var christmas2026 = new DateTime(2026, 12, 25); - Assert.False(defaultCalculator.IsBusinessDay(christmas2026)); + // Christmas 2026 is a regular weekday when no holidays configured + var christmas2026 = new DateTime(2026, 12, 25); // Friday + Assert.True(defaultCalculator.IsBusinessDay(christmas2026)); - // Check that a regular Wednesday is a business day + // Regular Wednesday is still a business day var regularDay = new DateTime(2026, 1, 7); // Wednesday Assert.True(defaultCalculator.IsBusinessDay(regularDay)); + + // Weekends are still non-business days + var saturday = new DateTime(2026, 1, 10); + Assert.False(defaultCalculator.IsBusinessDay(saturday)); + } + + [Fact] + public void IOptionsConstructor_LoadsHolidaysFromSettings() + { + // Arrange + var settings = Microsoft.Extensions.Options.Options.Create(new BusinessHoursSettings + { + Holidays = "2026-12-25,2026-01-01" + }); + + // Act + var calculator = new BusinessHoursCalculator(settings); + + // Assert + Assert.False(calculator.IsBusinessDay(new DateTime(2026, 12, 25))); + Assert.False(calculator.IsBusinessDay(new DateTime(2026, 1, 1))); + Assert.True(calculator.IsBusinessDay(new DateTime(2026, 1, 7))); // Wednesday } }