diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaApplicationHostOptionsValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaApplicationHostOptionsValidator.cs new file mode 100644 index 00000000..5fa08291 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaApplicationHostOptionsValidator.cs @@ -0,0 +1,32 @@ +using ZB.MOM.WW.Configuration; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; + +namespace ZB.MOM.WW.OtOpcUa.Host.Configuration; + +/// +/// Fail-fast startup validator for , built on the +/// shared ZB.MOM.WW.Configuration . The C# +/// defaults are all valid, so a host with no explicit "OpcUa" section passes untouched; +/// the validator exists to reject explicit prod/env overrides before the OPC UA SDK boots. +/// Identity/transport essentials (ApplicationName, ApplicationUri, +/// PublicHostname, PkiStoreRoot, OpcUaPort) must be present/valid and at +/// least one security profile must be enabled. Optional fields — ApplicationConfigPath, +/// PeerApplicationUris, AutoAcceptUntrustedClientCertificates, and +/// ProductUri — are intentionally not validated. Failure messages carry the real +/// "OpcUa:" section prefix matching the bound configuration section. +/// +public sealed class OpcUaApplicationHostOptionsValidator : OptionsValidatorBase +{ + /// + protected override void Validate(ValidationBuilder builder, OpcUaApplicationHostOptions o) + { + builder.Required(o.ApplicationName, "OpcUa:ApplicationName"); + builder.Required(o.ApplicationUri, "OpcUa:ApplicationUri"); + builder.Required(o.PublicHostname, "OpcUa:PublicHostname"); + builder.Required(o.PkiStoreRoot, "OpcUa:PkiStoreRoot"); + builder.Port(o.OpcUaPort, "OpcUa:OpcUaPort"); + // EnabledSecurityProfiles is typed IList, which does not implement IReadOnlyCollection; + // ToList() bridges to the shared MinCount primitive while preserving the count (and message). + builder.MinCount(o.EnabledSecurityProfiles?.ToList(), 1, "OpcUa:EnabledSecurityProfiles"); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs index 86d8774c..6087a6c6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs @@ -1,6 +1,6 @@ -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; @@ -20,7 +20,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa; /// public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposable { - private readonly IConfiguration _configuration; + private readonly OpcUaApplicationHostOptions _options; private readonly DeferredAddressSpaceSink _deferredSink; private readonly DeferredServiceLevelPublisher _deferredServiceLevel; private readonly IOpcUaUserAuthenticator _userAuthenticator; @@ -33,19 +33,19 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl /// /// Initializes a new instance of the OtOpcUaServerHostedService class. /// - /// The application configuration. + /// The validated OPC UA host options (bound from the OpcUa section and validated at startup via ValidateOnStart). /// The deferred address space sink that receives the real sink once the server is ready. /// The deferred service level publisher that receives the real publisher once the server is ready. /// The OPC UA user authenticator. /// The logger factory for creating loggers. public OtOpcUaServerHostedService( - IConfiguration configuration, + IOptions options, DeferredAddressSpaceSink deferredSink, DeferredServiceLevelPublisher deferredServiceLevel, IOpcUaUserAuthenticator userAuthenticator, ILoggerFactory loggerFactory) { - _configuration = configuration; + _options = options.Value; _deferredSink = deferredSink; _deferredServiceLevel = deferredServiceLevel; _userAuthenticator = userAuthenticator; @@ -59,8 +59,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl /// Cancellation token. public async Task StartAsync(CancellationToken cancellationToken) { - var options = new OpcUaApplicationHostOptions(); - _configuration.GetSection("OpcUa").Bind(options); + var options = _options; _server = new OtOpcUaSdkServer(); _appHost = new OpcUaApplicationHost( diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 7bdfe959..c43997d8 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -16,6 +16,7 @@ using ZB.MOM.WW.OtOpcUa.Host.Engines; using ZB.MOM.WW.OtOpcUa.Host.Health; using ZB.MOM.WW.OtOpcUa.Host.Observability; using ZB.MOM.WW.OtOpcUa.Host.OpcUa; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; using ZB.MOM.WW.OtOpcUa.Runtime; using ZB.MOM.WW.OtOpcUa.Security; @@ -102,6 +103,12 @@ if (hasDriver) builder.Services.AddSingleton(); builder.Services.AddSingleton(); + // Bind + validate the OPC UA host options the same way (fail-fast at start via ValidateOnStart) + // and let OtOpcUaServerHostedService consume the validated IOptions instance rather than + // re-binding the section imperatively. Defaults pass; this guards explicit prod/env overrides. + builder.Services.AddValidatedOptions( + builder.Configuration, "OpcUa"); + builder.Services.AddHostedService(); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/OpcUaApplicationHostOptionsValidatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/OpcUaApplicationHostOptionsValidatorTests.cs new file mode 100644 index 00000000..15c62020 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/OpcUaApplicationHostOptionsValidatorTests.cs @@ -0,0 +1,59 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Host.Configuration; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; + +namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; + +/// +/// Task 4 — verifies the net-new (built on the +/// shared ZB.MOM.WW.Configuration OptionsValidatorBase/ValidationBuilder) that +/// gates the OPC UA host options at startup. The C# defaults are all valid so a host with no +/// explicit "OpcUa" section still passes; the validator exists to reject explicit +/// prod/env overrides. Failure messages carry the real "OpcUa:" section prefix and the +/// exact shared-primitive wording so they read correctly when surfaced via ValidateOnStart. +/// +public sealed class OpcUaApplicationHostOptionsValidatorTests +{ + private static readonly OpcUaApplicationHostOptionsValidator Sut = new(); + + /// The C# defaults (the as-bound shape when the section is absent) pass validation. + [Fact] + public void Default_options_succeed() + { + Sut.Validate(null, new OpcUaApplicationHostOptions()).Succeeded.ShouldBeTrue(); + } + + /// A port of 0 reports the shared port-range failure with the OpcUa prefix. + [Fact] + public void Zero_port_fails() + { + var result = Sut.Validate(null, new OpcUaApplicationHostOptions { OpcUaPort = 0 }); + + result.Failed.ShouldBeTrue(); + result.Failures.ShouldContain("OpcUa:OpcUaPort must be between 1 and 65535 (was 0)"); + } + + /// A blank public hostname reports the shared required failure with the OpcUa prefix. + [Fact] + public void Blank_public_hostname_fails() + { + var result = Sut.Validate(null, new OpcUaApplicationHostOptions { PublicHostname = "" }); + + result.Failed.ShouldBeTrue(); + result.Failures.ShouldContain("OpcUa:PublicHostname is required"); + } + + /// An empty security-profile list reports the shared min-count failure with the OpcUa prefix. + [Fact] + public void Empty_security_profiles_fails() + { + var result = Sut.Validate(null, new OpcUaApplicationHostOptions + { + EnabledSecurityProfiles = new List(), + }); + + result.Failed.ShouldBeTrue(); + result.Failures.ShouldContain("OpcUa:EnabledSecurityProfiles must contain at least 1 item(s) (had 0)"); + } +}