using System.Diagnostics; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; 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 = new LdapOpcUaUserAuthenticator( ldap, ScopeFactoryWith(mapper), NullLogger.Instance); 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)); } /// 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; } } /// 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)); } }