diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs index dd01aa3a..c662e96c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs @@ -7,7 +7,8 @@ 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 +/// TCP port; when disabled — or when DevStubMode bypasses the real bind — 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. @@ -17,7 +18,10 @@ public sealed class LdapOptionsValidator : OptionsValidatorBase /// protected override void Validate(ValidationBuilder builder, LdapOptions options) { - if (!options.Enabled) return; + // Skip the real-LDAP field checks when LDAP login is disabled, or when the dev stub is + // active — DevStubMode bypasses the real bind entirely, so Server/SearchBase/Port are + // irrelevant and would otherwise force dev configs to carry meaningless placeholders. + if (!options.Enabled || options.DevStubMode) return; builder.RequireThat(!string.IsNullOrWhiteSpace(options.Server), "Ldap:Server is required when LDAP login is enabled."); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaApplicationHostOptionsValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaApplicationHostOptionsValidator.cs index 5fa08291..789afca9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaApplicationHostOptionsValidator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaApplicationHostOptionsValidator.cs @@ -25,8 +25,9 @@ public sealed class OpcUaApplicationHostOptionsValidator : OptionsValidatorBase< 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). + // EnabledSecurityProfiles is declared as IList — that interface does not derive from + // IReadOnlyCollection, so it can't bind to MinCount's IReadOnlyCollection parameter + // directly. ToList() bridges to the shared 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 6087a6c6..32506e50 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs @@ -59,11 +59,9 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl /// Cancellation token. public async Task StartAsync(CancellationToken cancellationToken) { - var options = _options; - _server = new OtOpcUaSdkServer(); _appHost = new OpcUaApplicationHost( - options, + _options, _loggerFactory.CreateLogger(), _userAuthenticator); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index c43997d8..482e6ddb 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -99,7 +99,7 @@ if (hasDriver) new RoslynScriptedAlarmEvaluator(sp.GetRequiredService().CreateLogger())); builder.Services.AddSingleton(sp => sp.GetRequiredService()); - builder.Services.AddValidatedOptions(builder.Configuration, "Ldap"); + builder.Services.AddValidatedOptions(builder.Configuration, LdapOptions.SectionName); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs index ee965b29..102671c0 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs @@ -2,12 +2,12 @@ namespace ZB.MOM.WW.OtOpcUa.Security.Ldap; /// /// LDAP + role-mapping configuration for the Admin UI. Bound from appsettings.json -/// Authentication:Ldap section. Defaults point at the local GLAuth dev instance (see +/// Security:Ldap section. Defaults point at the local GLAuth dev instance (see /// C:\publish\glauth\auth.md). /// public sealed class LdapOptions { - public const string SectionName = "Authentication:Ldap"; + public const string SectionName = "Security:Ldap"; /// Gets or sets a value indicating whether LDAP authentication is enabled. public bool Enabled { get; set; } = true; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsBindingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsBindingTests.cs new file mode 100644 index 00000000..8ff24c04 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsBindingTests.cs @@ -0,0 +1,62 @@ +using Microsoft.Extensions.Configuration; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Security.Ldap; + +namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; + +/// +/// Regression guard for the LDAP config-section fix. The real config (admin/driver/Development +/// overlays) lives under Security:Ldap, and must point +/// there so the configured DevStubMode actually binds. Previously the binders used the +/// nonexistent "Ldap"/"Authentication:Ldap" sections, so the dev stub never activated. +/// +public sealed class LdapOptionsBindingTests +{ + /// resolves to the real overlay section. + [Fact] + public void SectionName_is_Security_Ldap() + { + LdapOptions.SectionName.ShouldBe("Security:Ldap"); + } + + /// + /// Binding from reads the configured DevStubMode + /// from the real Security:Ldap section — proving the dev stub now takes effect. + /// + [Fact] + public void Binding_from_SectionName_reads_Security_Ldap_DevStubMode() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Security:Ldap:DevStubMode"] = "true", + }) + .Build(); + + var options = configuration.GetSection(LdapOptions.SectionName).Get(); + + options.ShouldNotBeNull(); + options.DevStubMode.ShouldBeTrue(); + } + + /// + /// Negative control: binding from the old (nonexistent) "Ldap" section against the same + /// Security:Ldap config does NOT pick up DevStubMode — it falls back to the C# + /// default (false). This is the pre-fix behaviour the change corrects. + /// + [Fact] + public void Binding_from_old_Ldap_section_does_not_read_DevStubMode() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Security:Ldap:DevStubMode"] = "true", + }) + .Build(); + + var options = configuration.GetSection("Ldap").Get() ?? new LdapOptions(); + + options.DevStubMode.ShouldBeFalse(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs index 8ab750ea..99f8d5ad 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs @@ -46,6 +46,25 @@ public sealed class LdapOptionsValidatorTests Sut.Validate(null, options).Succeeded.ShouldBeTrue(); } + /// + /// When the dev stub is active the real LDAP fields are irrelevant (the bind is bypassed), so + /// the gate skips the Server/SearchBase/Port checks even though LDAP is nominally enabled. + /// + [Fact] + public void DevStubMode_options_succeed_even_when_server_blank() + { + var options = new LdapOptions + { + Enabled = true, + DevStubMode = true, + 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()