diff --git a/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemGatewayOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemGatewayOptionsValidator.cs new file mode 100644 index 00000000..c3d7a25d --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemGatewayOptionsValidator.cs @@ -0,0 +1,33 @@ +using ZB.MOM.WW.Configuration; + +namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway; + +/// +/// Validates at startup. +/// bounds each outbound call, +/// is fed into +/// SocketsHttpHandler.MaxConnectionsPerServer (a non-positive value throws in the handler +/// constructor), and caps buffered +/// response bodies. Registered with ValidateOnStart() so a bad +/// ScadaBridge:ExternalSystemGateway section fails fast at boot with a clear, key-naming +/// message rather than crashing the HTTP client factory or silently disabling the gateway. +/// +public sealed class ExternalSystemGatewayOptionsValidator : OptionsValidatorBase +{ + /// + protected override void Validate(ValidationBuilder builder, ExternalSystemGatewayOptions options) + { + builder.RequireThat(options.DefaultHttpTimeout > TimeSpan.Zero, + $"ScadaBridge:ExternalSystemGateway:DefaultHttpTimeout must be a positive duration " + + $"(was {options.DefaultHttpTimeout}); it bounds each outbound external-system HTTP call."); + + builder.RequireThat(options.MaxConcurrentConnectionsPerSystem > 0, + $"ScadaBridge:ExternalSystemGateway:MaxConcurrentConnectionsPerSystem must be greater than zero " + + $"(was {options.MaxConcurrentConnectionsPerSystem}); it is fed into " + + "SocketsHttpHandler.MaxConnectionsPerServer, which rejects non-positive values."); + + builder.RequireThat(options.MaxResponseBodyBytes > 0, + $"ScadaBridge:ExternalSystemGateway:MaxResponseBodyBytes must be greater than zero " + + $"(was {options.MaxResponseBodyBytes}); it caps the outbound response body buffered into memory."); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ServiceCollectionExtensions.cs index d272313a..ccf78534 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Http; using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; @@ -21,7 +22,11 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddExternalSystemGateway(this IServiceCollection services) { services.AddOptions() - .BindConfiguration("ScadaBridge:ExternalSystemGateway"); + .BindConfiguration("ScadaBridge:ExternalSystemGateway") + .ValidateOnStart(); + services.TryAddEnumerable( + ServiceDescriptor.Singleton, + ExternalSystemGatewayOptionsValidator>()); services.AddHttpClient(); diff --git a/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.csproj b/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.csproj index 0cc9167c..58505b63 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.csproj @@ -13,6 +13,7 @@ + diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs index 4ef81823..0d9156fa 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection; using ZB.MOM.WW.Auth.AspNetCore; using ZB.MOM.WW.Health; @@ -291,7 +293,11 @@ try // Options binding SiteServiceRegistration.BindSharedOptions(builder.Services, builder.Configuration); builder.Services.Configure(builder.Configuration.GetSection("ScadaBridge:Security")); - builder.Services.Configure(builder.Configuration.GetSection("ScadaBridge:InboundApi")); + builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection("ScadaBridge:InboundApi")) + .ValidateOnStart(); + builder.Services.TryAddEnumerable( + ServiceDescriptor.Singleton, InboundApiOptionsValidator>()); builder.Services.Configure( builder.Configuration.GetSection(ZB.MOM.WW.ScadaBridge.DeploymentManager.ServiceCollectionExtensions.OptionsSection)); diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundApiOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundApiOptionsValidator.cs new file mode 100644 index 00000000..23bc7b0b --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundApiOptionsValidator.cs @@ -0,0 +1,31 @@ +using ZB.MOM.WW.Configuration; + +namespace ZB.MOM.WW.ScadaBridge.InboundAPI; + +/// +/// Validates at startup. +/// bounds each inbound-API method execution and caps the +/// accepted POST /api/{methodName} body before it is buffered — a zero/negative timeout would cancel +/// every request immediately (or never), and a non-positive body cap would reject every request with HTTP 413. +/// Registered with ValidateOnStart() so a bad ScadaBridge:InboundApi section fails fast at boot +/// with a clear, key-naming message rather than degrading the endpoint at runtime. +/// +/// is intentionally NOT validated here: an empty pepper is a +/// legitimate dev default, and production pepper strength is owned by the shared auth-key library / startup +/// validator, not by this options validator. +/// +/// +public sealed class InboundApiOptionsValidator : OptionsValidatorBase +{ + /// + protected override void Validate(ValidationBuilder builder, InboundApiOptions options) + { + builder.RequireThat(options.DefaultMethodTimeout > TimeSpan.Zero, + $"ScadaBridge:InboundApi:DefaultMethodTimeout must be a positive duration " + + $"(was {options.DefaultMethodTimeout}); it bounds each inbound-API method execution."); + + builder.RequireThat(options.MaxRequestBodyBytes > 0, + $"ScadaBridge:InboundApi:MaxRequestBodyBytes must be greater than zero " + + $"(was {options.MaxRequestBodyBytes}); it caps the accepted request body before buffering."); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ZB.MOM.WW.ScadaBridge.InboundAPI.csproj b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ZB.MOM.WW.ScadaBridge.InboundAPI.csproj index 665799b5..c25f4ae1 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ZB.MOM.WW.ScadaBridge.InboundAPI.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ZB.MOM.WW.ScadaBridge.InboundAPI.csproj @@ -32,6 +32,7 @@ AddZbApiKeyAuth (DI helper) lives in this package and brings the Abstractions contracts (IApiKeyVerifier / ApiKeyOptions) transitively. --> + diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementServiceOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementServiceOptionsValidator.cs new file mode 100644 index 00000000..46cce9dc --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementServiceOptionsValidator.cs @@ -0,0 +1,30 @@ +using ZB.MOM.WW.Configuration; + +namespace ZB.MOM.WW.ScadaBridge.ManagementService; + +/// +/// Validates at startup. Both +/// and +/// are used as Ask timeouts against +/// the ManagementActor; a zero/negative value makes every management command fail immediately (or Ask +/// forever). Registered with ValidateOnStart() so a bad ScadaBridge:ManagementService +/// section fails fast at boot with a clear, key-naming message. +/// +/// is intentionally NOT required positive: +/// a non-positive value legitimately disables server-side secured-write expiry. +/// +/// +public sealed class ManagementServiceOptionsValidator : OptionsValidatorBase +{ + /// + protected override void Validate(ValidationBuilder builder, ManagementServiceOptions options) + { + builder.RequireThat(options.CommandTimeout > TimeSpan.Zero, + $"ScadaBridge:ManagementService:CommandTimeout must be a positive duration " + + $"(was {options.CommandTimeout}); it is the Ask timeout for management commands."); + + builder.RequireThat(options.LongRunningCommandTimeout > TimeSpan.Zero, + $"ScadaBridge:ManagementService:LongRunningCommandTimeout must be a positive duration " + + $"(was {options.LongRunningCommandTimeout}); it is the Ask timeout for transport/deploy commands."); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ServiceCollectionExtensions.cs index d32edc0b..1a1221f7 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ServiceCollectionExtensions.cs @@ -1,4 +1,6 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; namespace ZB.MOM.WW.ScadaBridge.ManagementService; @@ -13,7 +15,11 @@ public static class ServiceCollectionExtensions { services.AddSingleton(); services.AddOptions() - .BindConfiguration("ScadaBridge:ManagementService"); + .BindConfiguration("ScadaBridge:ManagementService") + .ValidateOnStart(); + services.TryAddEnumerable( + ServiceDescriptor.Singleton, + ManagementServiceOptionsValidator>()); return services; } } diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ZB.MOM.WW.ScadaBridge.ManagementService.csproj b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ZB.MOM.WW.ScadaBridge.ManagementService.csproj index 47acc975..aa87c738 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ZB.MOM.WW.ScadaBridge.ManagementService.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ZB.MOM.WW.ScadaBridge.ManagementService.csproj @@ -14,6 +14,7 @@ + diff --git a/src/ZB.MOM.WW.ScadaBridge.NotificationService/NotificationOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.NotificationService/NotificationOptionsValidator.cs new file mode 100644 index 00000000..70b30cdc --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.NotificationService/NotificationOptionsValidator.cs @@ -0,0 +1,27 @@ +using ZB.MOM.WW.Configuration; + +namespace ZB.MOM.WW.ScadaBridge.NotificationService; + +/// +/// Validates at startup. The values are the SMTP fallback used +/// by the central Email delivery adapter when the deployed SmtpConfiguration leaves the +/// corresponding field unset: becomes an +/// SMTP connect timeout and caps the +/// connection pool — a non-positive value there is not a usable fallback. Registered with +/// ValidateOnStart() so a bad ScadaBridge:Notification section fails fast at boot with +/// a clear, key-naming message. +/// +public sealed class NotificationOptionsValidator : OptionsValidatorBase +{ + /// + protected override void Validate(ValidationBuilder builder, NotificationOptions options) + { + builder.RequireThat(options.ConnectionTimeoutSeconds > 0, + $"ScadaBridge:Notification:ConnectionTimeoutSeconds must be greater than zero " + + $"(was {options.ConnectionTimeoutSeconds}); it is the SMTP connect-timeout fallback."); + + builder.RequireThat(options.MaxConcurrentConnections > 0, + $"ScadaBridge:Notification:MaxConcurrentConnections must be greater than zero " + + $"(was {options.MaxConcurrentConnections}); it caps the SMTP connection pool."); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.NotificationService/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.NotificationService/ServiceCollectionExtensions.cs index 9ebdeda6..bdc1ce25 100644 --- a/src/ZB.MOM.WW.ScadaBridge.NotificationService/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.NotificationService/ServiceCollectionExtensions.cs @@ -1,4 +1,6 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; namespace ZB.MOM.WW.ScadaBridge.NotificationService; @@ -18,7 +20,11 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddNotificationService(this IServiceCollection services) { services.AddOptions() - .BindConfiguration("ScadaBridge:Notification"); + .BindConfiguration("ScadaBridge:Notification") + .ValidateOnStart(); + services.TryAddEnumerable( + ServiceDescriptor.Singleton, + NotificationOptionsValidator>()); services.AddHttpClient(); services.AddSingleton(); diff --git a/src/ZB.MOM.WW.ScadaBridge.NotificationService/ZB.MOM.WW.ScadaBridge.NotificationService.csproj b/src/ZB.MOM.WW.ScadaBridge.NotificationService/ZB.MOM.WW.ScadaBridge.NotificationService.csproj index a0a11ddf..3a3979cc 100644 --- a/src/ZB.MOM.WW.ScadaBridge.NotificationService/ZB.MOM.WW.ScadaBridge.NotificationService.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.NotificationService/ZB.MOM.WW.ScadaBridge.NotificationService.csproj @@ -13,6 +13,7 @@ + diff --git a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemGatewayOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemGatewayOptionsValidatorTests.cs new file mode 100644 index 00000000..b5fc1af4 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemGatewayOptionsValidatorTests.cs @@ -0,0 +1,67 @@ +using Microsoft.Extensions.Options; + +namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests; + +/// +/// Arch-review 08 §1.5: feeds a per-call HTTP +/// timeout, a per-system connection cap (used as SocketsHttpHandler.MaxConnectionsPerServer), +/// and a response-body byte cap into the gateway. A zero/negative timeout or a non-positive +/// count crashes the handler or silently disables the gateway; reject them fast at startup. +/// +public class ExternalSystemGatewayOptionsValidatorTests +{ + private static ValidateOptionsResult Validate(ExternalSystemGatewayOptions options) => + new ExternalSystemGatewayOptionsValidator().Validate(Options.DefaultName, options); + + [Fact] + public void DefaultOptions_AreValid() + { + var result = Validate(new ExternalSystemGatewayOptions()); + Assert.True(result.Succeeded, result.FailureMessage); + } + + [Fact] + public void ZeroDefaultHttpTimeout_IsRejected() + { + var result = Validate(new ExternalSystemGatewayOptions { DefaultHttpTimeout = TimeSpan.Zero }); + + Assert.True(result.Failed); + Assert.Contains("DefaultHttpTimeout", result.FailureMessage); + } + + [Fact] + public void NegativeDefaultHttpTimeout_IsRejected() + { + var result = Validate(new ExternalSystemGatewayOptions { DefaultHttpTimeout = TimeSpan.FromSeconds(-1) }); + + Assert.True(result.Failed); + Assert.Contains("DefaultHttpTimeout", result.FailureMessage); + } + + [Fact] + public void ZeroMaxConcurrentConnectionsPerSystem_IsRejected() + { + var result = Validate(new ExternalSystemGatewayOptions { MaxConcurrentConnectionsPerSystem = 0 }); + + Assert.True(result.Failed); + Assert.Contains("MaxConcurrentConnectionsPerSystem", result.FailureMessage); + } + + [Fact] + public void NegativeMaxConcurrentConnectionsPerSystem_IsRejected() + { + var result = Validate(new ExternalSystemGatewayOptions { MaxConcurrentConnectionsPerSystem = -1 }); + + Assert.True(result.Failed); + Assert.Contains("MaxConcurrentConnectionsPerSystem", result.FailureMessage); + } + + [Fact] + public void ZeroMaxResponseBodyBytes_IsRejected() + { + var result = Validate(new ExternalSystemGatewayOptions { MaxResponseBodyBytes = 0 }); + + Assert.True(result.Failed); + Assert.Contains("MaxResponseBodyBytes", result.FailureMessage); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundApiOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundApiOptionsValidatorTests.cs new file mode 100644 index 00000000..65af18bc --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundApiOptionsValidatorTests.cs @@ -0,0 +1,68 @@ +using Microsoft.Extensions.Options; + +namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; + +/// +/// Arch-review 08 §1.5: feeds a per-request timeout +/// () and a body-size cap +/// () into the inbound-API request +/// pipeline; a zero/negative timeout or a non-positive body cap must be rejected fast at +/// startup with a clear, key-naming message rather than silently degrading the endpoint. +/// The is deliberately NOT required non-empty +/// here (empty is a legitimate dev default; production pepper strength is enforced elsewhere). +/// +public class InboundApiOptionsValidatorTests +{ + private static ValidateOptionsResult Validate(InboundApiOptions options) => + new InboundApiOptionsValidator().Validate(Options.DefaultName, options); + + [Fact] + public void DefaultOptions_AreValid() + { + var result = Validate(new InboundApiOptions()); + Assert.True(result.Succeeded, result.FailureMessage); + } + + [Fact] + public void EmptyApiKeyPepper_IsValid() + { + var result = Validate(new InboundApiOptions { ApiKeyPepper = string.Empty }); + Assert.True(result.Succeeded, result.FailureMessage); + } + + [Fact] + public void ZeroDefaultMethodTimeout_IsRejected() + { + var result = Validate(new InboundApiOptions { DefaultMethodTimeout = TimeSpan.Zero }); + + Assert.True(result.Failed); + Assert.Contains("DefaultMethodTimeout", result.FailureMessage); + } + + [Fact] + public void NegativeDefaultMethodTimeout_IsRejected() + { + var result = Validate(new InboundApiOptions { DefaultMethodTimeout = TimeSpan.FromSeconds(-1) }); + + Assert.True(result.Failed); + Assert.Contains("DefaultMethodTimeout", result.FailureMessage); + } + + [Fact] + public void ZeroMaxRequestBodyBytes_IsRejected() + { + var result = Validate(new InboundApiOptions { MaxRequestBodyBytes = 0 }); + + Assert.True(result.Failed); + Assert.Contains("MaxRequestBodyBytes", result.FailureMessage); + } + + [Fact] + public void NegativeMaxRequestBodyBytes_IsRejected() + { + var result = Validate(new InboundApiOptions { MaxRequestBodyBytes = -1 }); + + Assert.True(result.Failed); + Assert.Contains("MaxRequestBodyBytes", result.FailureMessage); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementServiceOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementServiceOptionsValidatorTests.cs new file mode 100644 index 00000000..caec3995 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementServiceOptionsValidatorTests.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.Options; + +namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; + +/// +/// Arch-review 08 §1.5: supplies the Ask timeouts +/// the ManagementActor client uses for command and long-running transport/deploy calls. A +/// zero/negative timeout makes every command fail (or Ask forever); reject it fast at startup +/// with a key-naming message. is +/// deliberately NOT required positive — a non-positive value legitimately disables server-side +/// secured-write expiry. +/// +public class ManagementServiceOptionsValidatorTests +{ + private static ValidateOptionsResult Validate(ManagementServiceOptions options) => + new ManagementServiceOptionsValidator().Validate(Options.DefaultName, options); + + [Fact] + public void DefaultOptions_AreValid() + { + var result = Validate(new ManagementServiceOptions()); + Assert.True(result.Succeeded, result.FailureMessage); + } + + [Fact] + public void NonPositiveSecuredWritePendingTtl_IsValid() + { + // A non-positive TTL disables server-side expiry by design — not a config error. + var result = Validate(new ManagementServiceOptions { SecuredWritePendingTtl = TimeSpan.Zero }); + Assert.True(result.Succeeded, result.FailureMessage); + } + + [Fact] + public void ZeroCommandTimeout_IsRejected() + { + var result = Validate(new ManagementServiceOptions { CommandTimeout = TimeSpan.Zero }); + + Assert.True(result.Failed); + Assert.Contains("CommandTimeout", result.FailureMessage); + } + + [Fact] + public void NegativeCommandTimeout_IsRejected() + { + var result = Validate(new ManagementServiceOptions { CommandTimeout = TimeSpan.FromSeconds(-1) }); + + Assert.True(result.Failed); + Assert.Contains("CommandTimeout", result.FailureMessage); + } + + [Fact] + public void ZeroLongRunningCommandTimeout_IsRejected() + { + var result = Validate(new ManagementServiceOptions { LongRunningCommandTimeout = TimeSpan.Zero }); + + Assert.True(result.Failed); + Assert.Contains("LongRunningCommandTimeout", result.FailureMessage); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/NotificationOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/NotificationOptionsValidatorTests.cs new file mode 100644 index 00000000..059be118 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/NotificationOptionsValidatorTests.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.Options; + +namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests; + +/// +/// Arch-review 08 §1.5: supplies the SMTP fallback +/// connection timeout and max-concurrent-connections used by the central Email delivery +/// adapter when the deployed SmtpConfiguration leaves them unset. A non-positive +/// value there is not a usable fallback; reject it fast at startup with a key-naming message. +/// +public class NotificationOptionsValidatorTests +{ + private static ValidateOptionsResult Validate(NotificationOptions options) => + new NotificationOptionsValidator().Validate(Options.DefaultName, options); + + [Fact] + public void DefaultOptions_AreValid() + { + var result = Validate(new NotificationOptions()); + Assert.True(result.Succeeded, result.FailureMessage); + } + + [Fact] + public void ZeroConnectionTimeoutSeconds_IsRejected() + { + var result = Validate(new NotificationOptions { ConnectionTimeoutSeconds = 0 }); + + Assert.True(result.Failed); + Assert.Contains("ConnectionTimeoutSeconds", result.FailureMessage); + } + + [Fact] + public void NegativeConnectionTimeoutSeconds_IsRejected() + { + var result = Validate(new NotificationOptions { ConnectionTimeoutSeconds = -1 }); + + Assert.True(result.Failed); + Assert.Contains("ConnectionTimeoutSeconds", result.FailureMessage); + } + + [Fact] + public void ZeroMaxConcurrentConnections_IsRejected() + { + var result = Validate(new NotificationOptions { MaxConcurrentConnections = 0 }); + + Assert.True(result.Failed); + Assert.Contains("MaxConcurrentConnections", result.FailureMessage); + } + + [Fact] + public void NegativeMaxConcurrentConnections_IsRejected() + { + var result = Validate(new NotificationOptions { MaxConcurrentConnections = -1 }); + + Assert.True(result.Failed); + Assert.Contains("MaxConcurrentConnections", result.FailureMessage); + } +}