fix(auth): OtOpcUa 1.2 review fixes — startup insecure-transport guard + Ldaps in prod overlays, test fidelity, 0.1.1 pin

This commit is contained in:
Joseph Doherty
2026-06-02 01:37:29 -04:00
parent 257caa7bd1
commit c4f315ec90
9 changed files with 226 additions and 20 deletions
@@ -1,7 +1,9 @@
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;
@@ -60,3 +62,64 @@ public sealed class LdapOptionsBindingTests
options.DevStubMode.ShouldBeFalse();
}
}
/// <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();
}
}
@@ -2,6 +2,7 @@ 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;
@@ -10,13 +11,18 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <c>ZB.MOM.WW.Configuration</c> <c>OptionsValidatorBase</c>/<c>ValidationBuilder</c>) gates on
/// <see cref="LdapOptions.Enabled"/>, and that when enabled it requires <c>Server</c>,
/// <c>SearchBase</c>, and a valid <c>Port</c>. Failure messages carry the real <c>"Ldap:"</c>
/// section prefix so they read correctly when surfaced at host startup.
/// section prefix so they read correctly when surfaced at host startup. Also verifies the
/// insecure-transport startup guard: a real-LDAP config selecting plaintext transport without
/// <see cref="LdapOptions.AllowInsecure"/> fails fast at boot.
/// </summary>
public sealed class LdapOptionsValidatorTests
{
private static readonly LdapOptionsValidator Sut = new();
/// <summary>Valid enabled options pass validation.</summary>
private const string InsecureTransportFailure =
"LDAP transport is None (plaintext) but AllowInsecure is false — set Transport to Ldaps/StartTls or set AllowInsecure for dev.";
/// <summary>Valid enabled options (a TLS transport) pass validation.</summary>
[Fact]
public void Valid_enabled_options_succeed()
{
@@ -26,6 +32,102 @@ public sealed class LdapOptionsValidatorTests
Server = "ldap",
SearchBase = "dc=x",
Port = 389,
Transport = LdapTransport.Ldaps,
};
Sut.Validate(null, options).Succeeded.ShouldBeTrue();
}
/// <summary>
/// Insecure-transport guard: an enabled real-LDAP config that selects plaintext
/// <see cref="LdapTransport.None"/> without <see cref="LdapOptions.AllowInsecure"/> fails
/// startup validation with the guard message.
/// </summary>
[Fact]
public void Enabled_with_plaintext_transport_and_not_allow_insecure_fails()
{
var options = new LdapOptions
{
Enabled = true,
Server = "ldap",
SearchBase = "dc=x",
Port = 389,
Transport = LdapTransport.None,
AllowInsecure = false,
};
var result = Sut.Validate(null, options);
result.Failed.ShouldBeTrue();
result.Failures.ShouldContain(InsecureTransportFailure);
}
/// <summary>A TLS transport (<see cref="LdapTransport.Ldaps"/>) satisfies the guard.</summary>
[Fact]
public void Enabled_with_ldaps_transport_passes_guard()
{
var options = new LdapOptions
{
Enabled = true,
Server = "ldap",
SearchBase = "dc=x",
Port = 636,
Transport = LdapTransport.Ldaps,
};
Sut.Validate(null, options).Succeeded.ShouldBeTrue();
}
/// <summary>
/// Explicit opt-in: plaintext transport with <see cref="LdapOptions.AllowInsecure"/> set is
/// permitted (dev/test escape hatch), so the guard does not trip.
/// </summary>
[Fact]
public void Enabled_plaintext_with_allow_insecure_passes_guard()
{
var options = new LdapOptions
{
Enabled = true,
Server = "ldap",
SearchBase = "dc=x",
Port = 389,
Transport = LdapTransport.None,
AllowInsecure = true,
};
Sut.Validate(null, options).Succeeded.ShouldBeTrue();
}
/// <summary>
/// DevStubMode is exempt from the insecure-transport guard: the dev stub bypasses the real
/// bind, so plaintext transport is irrelevant and must not block boot.
/// </summary>
[Fact]
public void DevStubMode_with_plaintext_transport_passes_guard()
{
var options = new LdapOptions
{
Enabled = true,
DevStubMode = true,
Transport = LdapTransport.None,
AllowInsecure = false,
};
Sut.Validate(null, options).Succeeded.ShouldBeTrue();
}
/// <summary>
/// A disabled config is exempt from the insecure-transport guard even with plaintext
/// transport — LDAP login never runs, so the guard must not trip.
/// </summary>
[Fact]
public void Disabled_with_plaintext_transport_passes_guard()
{
var options = new LdapOptions
{
Enabled = false,
Transport = LdapTransport.None,
AllowInsecure = false,
};
Sut.Validate(null, options).Succeeded.ShouldBeTrue();