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);
+ }
+}