Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsBindingTests.cs
T
Joseph Doherty 65fda7237f refactor(acl): delete the LDAP data-plane authenticator and its tests (#520)
Stage 2b. LdapOpcUaUserAuthenticator was left registered nowhere by stage 2a;
this removes it, its three test files, and the config surface that existed only
to serve it.

Deleted: LdapOpcUaUserAuthenticator, LdapOpcUaUserAuthenticatorTests,
LdapAuthResilienceTests, LdapGlauthOutageLiveTests (19 tests).

Also removed four Security:Ldap keys — AuthTimeoutMs,
MaxConcurrentAuthentications, OutageFailureThreshold, OutageCooldownSeconds —
plus their validator rules and binding/validator test coverage. Verified first
that the ONLY consumers were the deleted class and the validator rules asserting
them; none appear in any checked-in appsettings. Leaving them would have created
exactly the dead-config surface #521/#524/#525 were filed to clear up.

Worth stating why that resilience machinery does not need relocating rather than
just deleting. It existed because the OPC UA SDK raises impersonation
SYNCHRONOUSLY under a SessionManager-wide event lock, so a hung LDAP bind there
serialized every session activation server-wide — Anonymous and X509 included.
Key verification is one indexed lookup plus one HMAC with no network I/O, so the
hazard the wall-clock bound, concurrency cap and outage circuit guarded against
no longer exists on that path. LDAP now runs only on AdminUI login, an ordinary
async request path where a slow directory affects the one request making it.

ConnectionTimeoutMs is RETAINED: it is projected into the shared library and is
not authenticator-specific.

Docs corrected rather than left to rot:
- CLAUDE.md's "LDAP Authentication" section described the deleted file as the
  data-plane authenticator. Rewritten, including a warning not to "restore" it on
  seeing the LDAP wiring that AdminUI legitimately still needs.
- infra/README §5b (the GLAuth outage live gate) marked RETIRED with the reason,
  rather than silently dropped — the harness GLAuth is still used by
  OTOPCUA_HARNESS_USE_LDAP=1 and is unaffected.
- docs/security.md's five-key resilience table cut to the one surviving key, and
  a new API-key identity section added.
Historical archreview/ records are left as-is; they are dated accounts.

Host.IntegrationTests 211 tests (was 230, i.e. exactly the 19 removed) with the
same 3 pre-existing failures verified against clean master. Host 37, Security 89.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-31 13:08:50 -04:00

164 lines
6.4 KiB
C#

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;
/// <summary>
/// Regression guard for the LDAP config-section fix. The real config (admin/driver/Development
/// overlays) lives under <c>Security:Ldap</c>, and <see cref="LdapOptions.SectionName"/> must point
/// there so the configured <c>DevStubMode</c> actually binds. Previously the binders used the
/// nonexistent <c>"Ldap"</c>/<c>"Authentication:Ldap"</c> sections, so the dev stub never activated.
/// </summary>
public sealed class LdapOptionsBindingTests
{
/// <summary><see cref="LdapOptions.SectionName"/> resolves to the real overlay section.</summary>
[Fact]
public void SectionName_is_Security_Ldap()
{
LdapOptions.SectionName.ShouldBe("Security:Ldap");
}
/// <summary>
/// Binding from <see cref="LdapOptions.SectionName"/> reads the configured <c>DevStubMode</c>
/// from the real <c>Security:Ldap</c> section — proving the dev stub now takes effect.
/// </summary>
[Fact]
public void Binding_from_SectionName_reads_Security_Ldap_DevStubMode()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Security:Ldap:DevStubMode"] = "true",
})
.Build();
var options = configuration.GetSection(LdapOptions.SectionName).Get<LdapOptions>();
options.ShouldNotBeNull();
options.DevStubMode.ShouldBeTrue();
}
/// <summary>
/// Negative control: binding from the old (nonexistent) <c>"Ldap"</c> section against the same
/// <c>Security:Ldap</c> config does NOT pick up <c>DevStubMode</c> — it falls back to the C#
/// default (false). This is the pre-fix behaviour the change corrects.
/// </summary>
[Fact]
public void Binding_from_old_Ldap_section_does_not_read_DevStubMode()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Security:Ldap:DevStubMode"] = "true",
})
.Build();
var options = configuration.GetSection("Ldap").Get<LdapOptions>() ?? new LdapOptions();
options.DevStubMode.ShouldBeFalse();
}
/// <summary>
/// <c>ConnectionTimeoutMs</c> binds from the real <c>Security:Ldap</c> section. The other four S2
/// resilience keys were removed with <c>LdapOpcUaUserAuthenticator</c> in #520 — they bounded an
/// LDAP bind running inline on OPC UA session activation, and no remaining consumer does that.
/// </summary>
[Fact]
public void Binding_reads_the_connection_timeout_key()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Security:Ldap:ConnectionTimeoutMs"] = "7000",
})
.Build();
var options = configuration.GetSection(LdapOptions.SectionName).Get<LdapOptions>();
options.ShouldNotBeNull();
options.ConnectionTimeoutMs.ShouldBe(7000);
}
/// <summary>The connection timeout carries its documented default when absent from config.</summary>
[Fact]
public void Connection_timeout_default_holds_when_absent()
{
new LdapOptions().ConnectionTimeoutMs.ShouldBe(10000);
}
/// <summary><c>ConnectionTimeoutMs</c> is projected onto the shared-library options so the
/// socket-connect + per-search timeout is bounded inside the directory client.</summary>
[Fact]
public void ToLibraryOptions_projects_ConnectionTimeoutMs()
{
var options = new LdapOptions { ConnectionTimeoutMs = 4321 };
options.ToLibraryOptions().ConnectionTimeoutMs.ShouldBe(4321);
}
}
/// <summary>
/// End-to-end guard for the shipped production overlays: binds each of the three prod overlay
/// files' real <c>Security:Ldap</c> section (the same files the host loads at boot, copied into the
/// test output via the Host project reference) and runs the <see cref="LdapOptionsValidator"/> the
/// host wires via <c>AddValidatedOptions</c>. 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 <c>Development</c> overlay (DevStubMode) is verified to
/// pass via the guard exemption.
/// </summary>
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<LdapOptions>() ?? 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();
}
}