using System.Diagnostics; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Shouldly; using Xunit; using ZB.MOM.WW.Auth.Abstractions.Roles; using ZB.MOM.WW.OtOpcUa.Host.OpcUa; using ZB.MOM.WW.OtOpcUa.Security.Ldap; 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); } 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; } } /// 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)); } }