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