From f35ebd7aaf76721fde571266be73a68b8a7dae4c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 18:32:44 -0400 Subject: [PATCH] feat: add fail-fast LDAP options validation in OtOpcUa via ZB.MOM.WW.Configuration --- .../Configuration/LdapOptionsValidator.cs | 28 +++++++ src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 4 +- .../ZB.MOM.WW.OtOpcUa.Host.csproj | 1 + .../LdapOptionsValidatorTests.cs | 84 +++++++++++++++++++ 4 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs new file mode 100644 index 00000000..dd01aa3a --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs @@ -0,0 +1,28 @@ +using ZB.MOM.WW.Configuration; +using ZB.MOM.WW.OtOpcUa.Security.Ldap; + +namespace ZB.MOM.WW.OtOpcUa.Host.Configuration; + +/// +/// Fail-fast startup validator for , built on the shared +/// ZB.MOM.WW.Configuration . When LDAP login +/// is enabled, Server and SearchBase must be set and Port must be a valid +/// TCP port; when disabled, all checks are skipped. ServiceAccountDn/Password are +/// intentionally not required — an empty pair selects the direct-bind path (see +/// ). Failure messages carry the real "Ldap:" +/// section prefix matching the bound configuration section. +/// +public sealed class LdapOptionsValidator : OptionsValidatorBase +{ + /// + protected override void Validate(ValidationBuilder builder, LdapOptions options) + { + if (!options.Enabled) return; + + builder.RequireThat(!string.IsNullOrWhiteSpace(options.Server), + "Ldap:Server is required when LDAP login is enabled."); + builder.RequireThat(!string.IsNullOrWhiteSpace(options.SearchBase), + "Ldap:SearchBase is required when LDAP login is enabled."); + builder.Port(options.Port, "Ldap:Port"); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index f0bacbd0..7bdfe959 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -10,6 +10,7 @@ using ZB.MOM.WW.OtOpcUa.ControlPlane; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.Engines; using ZB.MOM.WW.OtOpcUa.Host; +using ZB.MOM.WW.OtOpcUa.Host.Configuration; using ZB.MOM.WW.OtOpcUa.Host.Drivers; using ZB.MOM.WW.OtOpcUa.Host.Engines; using ZB.MOM.WW.OtOpcUa.Host.Health; @@ -20,6 +21,7 @@ using ZB.MOM.WW.OtOpcUa.Runtime; using ZB.MOM.WW.OtOpcUa.Security; using ZB.MOM.WW.OtOpcUa.Security.Endpoints; using ZB.MOM.WW.OtOpcUa.Security.Ldap; +using ZB.MOM.WW.Configuration; using ZB.MOM.WW.Telemetry.Serilog; // Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser. @@ -96,7 +98,7 @@ if (hasDriver) new RoslynScriptedAlarmEvaluator(sp.GetRequiredService().CreateLogger())); builder.Services.AddSingleton(sp => sp.GetRequiredService()); - builder.Services.AddOptions().Bind(builder.Configuration.GetSection("Ldap")); + builder.Services.AddValidatedOptions(builder.Configuration, "Ldap"); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj index 9317e6c4..6e540e7d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj @@ -32,6 +32,7 @@ + diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs new file mode 100644 index 00000000..8ab750ea --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs @@ -0,0 +1,84 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Host.Configuration; +using ZB.MOM.WW.OtOpcUa.Security.Ldap; + +namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; + +/// +/// Task 3 — verifies the net-new (built on the shared +/// ZB.MOM.WW.Configuration OptionsValidatorBase/ValidationBuilder) gates on +/// , and that when enabled it requires Server, +/// SearchBase, and a valid Port. Failure messages carry the real "Ldap:" +/// section prefix so they read correctly when surfaced at host startup. +/// +public sealed class LdapOptionsValidatorTests +{ + private static readonly LdapOptionsValidator Sut = new(); + + /// Valid enabled options pass validation. + [Fact] + public void Valid_enabled_options_succeed() + { + var options = new LdapOptions + { + Enabled = true, + Server = "ldap", + SearchBase = "dc=x", + Port = 389, + }; + + Sut.Validate(null, options).Succeeded.ShouldBeTrue(); + } + + /// When LDAP is disabled all checks are skipped, so a blank config still passes. + [Fact] + public void Disabled_options_succeed_even_when_blank() + { + var options = new LdapOptions + { + Enabled = false, + Server = string.Empty, + SearchBase = string.Empty, + Port = 0, + }; + + Sut.Validate(null, options).Succeeded.ShouldBeTrue(); + } + + /// Enabled with a blank server reports the required-server failure. + [Fact] + public void Enabled_with_blank_server_fails() + { + var options = new LdapOptions + { + Enabled = true, + Server = string.Empty, + SearchBase = "dc=x", + Port = 389, + }; + + var result = Sut.Validate(null, options); + + result.Failed.ShouldBeTrue(); + result.Failures.ShouldContain("Ldap:Server is required when LDAP login is enabled."); + } + + /// Enabled with port 0 reports the port-range failure using the shared primitive wording. + [Fact] + public void Enabled_with_zero_port_fails() + { + var options = new LdapOptions + { + Enabled = true, + Server = "ldap", + SearchBase = "dc=x", + Port = 0, + }; + + var result = Sut.Validate(null, options); + + result.Failed.ShouldBeTrue(); + result.Failures.ShouldContain("Ldap:Port must be between 1 and 65535 (was 0)"); + } +}