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()