using System.Diagnostics;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
using LdapTransport = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
///
/// Resilience guard for (arch-review 03/S2). The OPC UA SDK
/// raises the impersonation callback synchronously under a SessionManager-wide event lock, so a
/// slow / hung LDAP bind inside it does not merely stall one SDK thread — it serializes EVERY session
/// activation server-wide. These tests prove the authenticator enforces a hard wall-clock bound,
/// bounded in-flight concurrency, and a directory-outage circuit at its own boundary (the SDK callback
/// being irreducibly synchronous), all fail-closed. No real LDAP server is used — a delaying fake
/// reproduces the outage shape offline.
///
public sealed class LdapAuthResilienceTests
{
///
/// RED repro (03/S2): a backend that takes 5 s must NOT hold the caller for 5 s — the authenticator's
/// wall-clock bound denies (fail-closed) well before then. On current code (no bound) this fails: the
/// call returns Allow after the full 5 s.
///
[Fact]
public async Task Authenticate_slow_backend_denies_within_AuthTimeout()
{
var ldap = new DelayingFakeLdap(
TimeSpan.FromSeconds(5),
new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty(), null));
var mapper = new FakeMapper(g => g.ToArray());
var sut = Build(ldap, mapper, new LdapOptions { AuthTimeoutMs = 200 });
var sw = Stopwatch.StartNew();
var result = await sut
.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
sw.Stop();
result.Success.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error!.ShouldContain("timed out");
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1.5));
}
/// A fast, healthy backend is unaffected by the resilience wrapping — success + roles pass
/// through untouched (03/S2).
[Fact]
public async Task Authenticate_fast_success_unaffected()
{
var ldap = new DelayingFakeLdap(
TimeSpan.Zero,
new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty(), null));
var mapper = new FakeMapper(g => g.Select(x => x == "configeditor" ? "Designer" : x).ToArray());
var sut = Build(ldap, mapper, new LdapOptions());
var result = await sut
.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
result.Success.ShouldBeTrue();
result.DisplayName.ShouldBe("Alice");
result.Roles.ShouldBe(new[] { "Designer" });
}
/// With the concurrency cap saturated (a parked in-flight bind holding the only slot), a
/// second call is denied "backend busy" within its own budget — it never waits behind the parked
/// bind indefinitely (03/S2).
[Fact]
public async Task Authenticate_saturated_concurrency_denies_busy()
{
var ldap = new GatedFakeLdap(
new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty(), null));
var mapper = new FakeMapper(g => g.ToArray());
// MaxConcurrentAuthentications = 1 → the second call cannot get a slot; small AuthTimeoutMs so the
// "busy" denial is quick and the first call's boundary also trips fast (its core stays parked,
// holding the slot).
var sut = Build(ldap, mapper, new LdapOptions { MaxConcurrentAuthentications = 1, AuthTimeoutMs = 300 });
// First call: acquires the only slot; its core parks in the gated fake.
var first = sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None);
// Wait until the fake has actually been entered (slot acquired, core parked).
var spin = Stopwatch.StartNew();
while (ldap.Entered == 0 && spin.Elapsed < TimeSpan.FromSeconds(5))
await Task.Delay(10, TestContext.Current.CancellationToken);
ldap.Entered.ShouldBe(1);
// Second concurrent call: the slot is held by the parked core, so acquisition times out → busy.
var second = await sut
.AuthenticateUserNameAsync("bob", "secret", CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
second.Success.ShouldBeFalse();
second.Error.ShouldNotBeNull();
second.Error!.ShouldContain("busy");
ldap.Entered.ShouldBe(1, "the second call must be denied without ever reaching the backend");
// Release the parked bind so the first call's core completes cleanly (no orphaned resources).
ldap.Release();
await first.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
}
/// After OutageFailureThreshold consecutive system-side failures the circuit opens and
/// the next call is fast-denied "backend unavailable" WITHOUT touching LDAP (03/S2).
[Fact]
public async Task Consecutive_system_failures_open_circuit_and_fast_deny()
{
var ldap = new DelayingFakeLdap(TimeSpan.Zero, SystemFailure());
var mapper = new FakeMapper(g => g.ToArray());
var sut = Build(ldap, mapper,
new LdapOptions { OutageFailureThreshold = 3, OutageCooldownSeconds = 60, AuthTimeoutMs = 5000 });
for (var i = 0; i < 3; i++)
{
var r = await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
r.Success.ShouldBeFalse();
}
ldap.Calls.ShouldBe(3);
var sw = Stopwatch.StartNew();
var blocked = await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
sw.Stop();
blocked.Success.ShouldBeFalse();
blocked.Error.ShouldNotBeNull();
blocked.Error!.ShouldContain("unavailable");
ldap.Calls.ShouldBe(3, "an open circuit must fast-deny without reaching the backend");
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1));
}
/// A user-side (credential) deny between system failures resets the streak, so the circuit
/// never opens — user denies are not directory evidence (03/S2).
[Fact]
public async Task User_side_deny_resets_the_system_failure_streak()
{
var ldap = new ScriptedFakeLdap(
SystemFailure(), SystemFailure(), UserDeny(), SystemFailure(), SystemFailure());
var mapper = new FakeMapper(g => g.ToArray());
var sut = Build(ldap, mapper,
new LdapOptions { OutageFailureThreshold = 3, OutageCooldownSeconds = 60, AuthTimeoutMs = 5000 });
// 5 scripted calls: sys, sys, user(reset), sys, sys → streak ends at 2 (< 3), circuit stays closed.
for (var i = 0; i < 5; i++)
{
await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
}
// A 6th call must still REACH the backend (circuit never opened).
await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
ldap.Calls.ShouldBe(6);
}
/// After the cooldown elapses the circuit half-opens: the next call probes the backend and a
/// success closes the circuit (03/S2).
[Fact]
public async Task Half_open_probe_after_cooldown_closes_on_success()
{
var ldap = new ScriptedFakeLdap(
SystemFailure(), SystemFailure(), SystemFailure(),
new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty(), null));
var mapper = new FakeMapper(g => g.ToArray());
var sut = Build(ldap, mapper,
new LdapOptions { OutageFailureThreshold = 3, OutageCooldownSeconds = 1, AuthTimeoutMs = 5000 });
for (var i = 0; i < 3; i++)
await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
ldap.Calls.ShouldBe(3);
// Circuit open: this call is fast-denied without touching the backend.
var blocked = await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
blocked.Error!.ShouldContain("unavailable");
ldap.Calls.ShouldBe(3);
// Wait past the cooldown, then a probe reaches the backend and succeeds → circuit closes.
await Task.Delay(TimeSpan.FromMilliseconds(1200), TestContext.Current.CancellationToken);
var probe = await sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
probe.Success.ShouldBeTrue();
ldap.Calls.ShouldBe(4, "the half-open probe reaches the backend");
}
/// OutageFailureThreshold = 0 disables the circuit — it never opens, every call reaches
/// the backend (03/S2).
[Fact]
public async Task Circuit_disabled_never_opens()
{
var ldap = new DelayingFakeLdap(TimeSpan.Zero, SystemFailure());
var mapper = new FakeMapper(g => g.ToArray());
var sut = Build(ldap, mapper,
new LdapOptions { OutageFailureThreshold = 0, OutageCooldownSeconds = 60, AuthTimeoutMs = 5000 });
for (var i = 0; i < 6; i++)
await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
ldap.Calls.ShouldBe(6, "a disabled circuit never fast-denies");
}
///
/// Outage-sim (offline-safe): the REAL + real shared library
/// pointed at an UNROUTABLE host (a TCP blackhole — the same outage shape as "stop the GLAuth
/// container"), driven through the REAL internal
/// block-bridge. The activation must fail bounded (well under the SDK's default ~10-20 s stall) with
/// and no granted identity (03/S2).
///
[Fact]
public async Task Activation_with_unreachable_directory_fails_within_bound()
{
var options = new LdapOptions
{
Enabled = true,
DevStubMode = false,
Server = "10.255.255.1", // RFC-blackhole: SYN goes nowhere
Port = 3893,
Transport = LdapTransport.None,
AllowInsecure = true,
SearchBase = "dc=zb,dc=local",
ConnectionTimeoutMs = 500,
AuthTimeoutMs = 1000,
};
var ldap = new OtOpcUaLdapAuthService(Options.Create(options), NullLogger.Instance);
var mapper = new FakeMapper(g => g.ToArray());
var authenticator = new LdapOpcUaUserAuthenticator(
ldap, ScopeFactoryWith(mapper), Options.Create(options),
NullLogger.Instance);
var token = new UserNameIdentityToken { UserName = "alice", DecryptedPassword = Encoding.UTF8.GetBytes("secret") };
var policy = new UserTokenPolicy(UserTokenType.UserName) { PolicyId = "username_basic256sha256" };
var args = new ImpersonateEventArgs(token, policy, new EndpointDescription());
var sw = Stopwatch.StartNew();
await Task.Run(() => OpcUaApplicationHost.HandleImpersonation(
authenticator, args, NullLogger