using Microsoft.Extensions.Configuration; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Host.Configuration; using ZB.MOM.WW.OtOpcUa.Security.Ldap; using LdapTransport = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport; 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(); } /// /// The five S2 resilience keys bind from the real Security:Ldap section (03/S2). /// [Fact] public void Binding_reads_resilience_keys() { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Security:Ldap:ConnectionTimeoutMs"] = "7000", ["Security:Ldap:AuthTimeoutMs"] = "12000", ["Security:Ldap:MaxConcurrentAuthentications"] = "5", ["Security:Ldap:OutageFailureThreshold"] = "4", ["Security:Ldap:OutageCooldownSeconds"] = "20", }) .Build(); var options = configuration.GetSection(LdapOptions.SectionName).Get(); options.ShouldNotBeNull(); options.ConnectionTimeoutMs.ShouldBe(7000); options.AuthTimeoutMs.ShouldBe(12000); options.MaxConcurrentAuthentications.ShouldBe(5); options.OutageFailureThreshold.ShouldBe(4); options.OutageCooldownSeconds.ShouldBe(20); } /// The resilience keys carry the documented defaults when absent from config. [Fact] public void Resilience_key_defaults_hold_when_absent() { var options = new LdapOptions(); options.ConnectionTimeoutMs.ShouldBe(10000); options.AuthTimeoutMs.ShouldBe(15000); options.MaxConcurrentAuthentications.ShouldBe(8); options.OutageFailureThreshold.ShouldBe(3); options.OutageCooldownSeconds.ShouldBe(15); } /// ConnectionTimeoutMs is projected onto the shared-library options so the /// socket-connect + per-search timeout is bounded inside the directory client. [Fact] public void ToLibraryOptions_projects_ConnectionTimeoutMs() { var options = new LdapOptions { ConnectionTimeoutMs = 4321 }; options.ToLibraryOptions().ConnectionTimeoutMs.ShouldBe(4321); } } /// /// End-to-end guard for the shipped production overlays: binds each of the three prod overlay /// files' real Security:Ldap section (the same files the host loads at boot, copied into the /// test output via the Host project reference) and runs the the /// host wires via AddValidatedOptions. Proves each prod overlay declares a TLS transport and /// therefore PASSES startup validation — i.e. the host actually boots with these overlays after the /// insecure-transport guard was added. The Development overlay (DevStubMode) is verified to /// pass via the guard exemption. /// public sealed class ProdOverlayValidationTests { private static readonly LdapOptionsValidator Sut = new(); private static LdapOptions BindOverlay(string fileName) { var path = Path.Combine(AppContext.BaseDirectory, fileName); File.Exists(path).ShouldBeTrue($"overlay '{fileName}' should be copied to the test output"); var configuration = new ConfigurationBuilder() .AddJsonFile(path, optional: false, reloadOnChange: false) .Build(); return configuration.GetSection(LdapOptions.SectionName).Get() ?? new LdapOptions(); } [Theory] [InlineData("appsettings.admin.json")] [InlineData("appsettings.driver.json")] [InlineData("appsettings.admin-driver.json")] public void Prod_overlay_declares_ldaps_transport(string fileName) { var options = BindOverlay(fileName); options.DevStubMode.ShouldBeFalse(); options.Transport.ShouldBe(LdapTransport.Ldaps); } [Theory] [InlineData("appsettings.admin.json")] [InlineData("appsettings.driver.json")] [InlineData("appsettings.admin-driver.json")] public void Prod_overlay_passes_startup_validation(string fileName) { var options = BindOverlay(fileName); // Match the host: these overlays only set Security:Ldap fields, so backfill the required // Server/SearchBase/Port the way the base C# defaults do (LdapOptions defaults are valid), // then validate exactly as AddValidatedOptions would at boot. Sut.Validate(null, options).Succeeded.ShouldBeTrue(); } [Fact] public void Development_overlay_passes_startup_validation_via_devstub_exemption() { var options = BindOverlay("appsettings.Development.json"); options.DevStubMode.ShouldBeTrue(); Sut.Validate(null, options).Succeeded.ShouldBeTrue(); } }