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.Instance), TestContext.Current.CancellationToken) .WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); sw.Stop(); args.Identity.ShouldBeNull(); args.IdentityValidationError.ShouldNotBeNull(); args.IdentityValidationError.Code.ShouldBe(Opc.Ua.StatusCodes.BadIdentityTokenRejected); sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(3)); } private static LdapAuthResult SystemFailure() => new(false, null, "u", Array.Empty(), Array.Empty(), "Unexpected authentication error") { IsSystemFailure = true }; private static LdapAuthResult UserDeny() => new(false, null, "u", Array.Empty(), Array.Empty(), "Invalid username or password") { IsSystemFailure = false }; private static LdapOpcUaUserAuthenticator Build( ILdapAuthService ldap, IGroupRoleMapper mapper, LdapOptions options) => new(ldap, ScopeFactoryWith(mapper), Options.Create(options), NullLogger.Instance); /// Builds an IServiceScopeFactory whose scopes resolve the supplied mapper. private static IServiceScopeFactory ScopeFactoryWith(IGroupRoleMapper mapper) { var services = new ServiceCollection(); services.AddScoped(_ => mapper); return services.BuildServiceProvider().GetRequiredService(); } /// /// A fake that awaits a configurable delay (observing the token) before /// returning a fixed result — the offline stand-in for a slow / hung directory. Counts invocations so /// tests can assert the outage circuit denies WITHOUT reaching the backend. /// private sealed class DelayingFakeLdap : ILdapAuthService { private readonly TimeSpan _delay; private readonly LdapAuthResult _then; private int _calls; /// Initializes the fake with a per-call delay and the result to return afterwards. /// How long each authenticate call blocks before returning. /// The result returned once the delay elapses. public DelayingFakeLdap(TimeSpan delay, LdapAuthResult then) { _delay = delay; _then = then; } /// The number of times the backend was actually invoked. public int Calls => Volatile.Read(ref _calls); /// Awaits the configured delay, then returns the canned result. /// The username (ignored). /// The password (ignored). /// Cancellation token, observed by the delay. public async Task AuthenticateAsync(string username, string password, CancellationToken ct = default) { Interlocked.Increment(ref _calls); await Task.Delay(_delay, ct).ConfigureAwait(false); return _then; } } /// /// A fake that parks ASYNCHRONOUSLY (awaits a /// ) until the test calls — used to hold an /// in-flight bind so the concurrency cap is deterministically saturated WITHOUT blocking a thread. /// Records how many calls have entered (past the slot acquire). /// private sealed class GatedFakeLdap : ILdapAuthService { private readonly TaskCompletionSource _park = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly LdapAuthResult _then; private int _entered; /// Initializes the fake with the result to return once released. /// The result returned once is called. public GatedFakeLdap(LdapAuthResult then) => _then = then; /// Number of authenticate calls that reached the backend body. public int Entered => Volatile.Read(ref _entered); /// Releases the parked bind so its awaiting call can complete. public void Release() => _park.TrySetResult(); /// Marks entry, awaits the parking gate, then returns the canned result. /// The username (ignored). /// The password (ignored). /// Cancellation token, observed while parked. public async Task AuthenticateAsync(string username, string password, CancellationToken ct = default) { Interlocked.Increment(ref _entered); await _park.Task.WaitAsync(ct).ConfigureAwait(false); return _then; } } /// /// A fake that returns a scripted sequence of results (one per call; /// the last entry repeats once exhausted). Counts calls so the outage-circuit tests can assert the /// backend was — or was NOT — reached on a given call. /// private sealed class ScriptedFakeLdap : ILdapAuthService { private readonly IReadOnlyList _script; private int _calls; /// Initializes the fake with the scripted results, returned in order. /// The results to return, one per call; the last repeats once exhausted. public ScriptedFakeLdap(params LdapAuthResult[] script) => _script = script; /// The number of times the backend was invoked. public int Calls => Volatile.Read(ref _calls); /// Returns the next scripted result (clamped to the last entry). /// The username (ignored). /// The password (ignored). /// The cancellation token (ignored). public Task AuthenticateAsync(string username, string password, CancellationToken ct = default) { var idx = Interlocked.Increment(ref _calls) - 1; return Task.FromResult(_script[Math.Min(idx, _script.Count - 1)]); } } /// Test group→role mapper driven by a delegate over the supplied groups. private sealed class FakeMapper(Func, IReadOnlyList> map) : IGroupRoleMapper { /// Maps groups to roles via the configured delegate; Scope is always null. /// The LDAP groups to map. /// The cancellation token. public Task> MapAsync(IReadOnlyList groups, CancellationToken ct) => Task.FromResult(new GroupRoleMapping(map(groups), Scope: null)); } }