diff --git a/archreview/plans/R2-08-ldap-historyread-async-plan.md.tasks.json b/archreview/plans/R2-08-ldap-historyread-async-plan.md.tasks.json index b48a529d..d780299b 100644 --- a/archreview/plans/R2-08-ldap-historyread-async-plan.md.tasks.json +++ b/archreview/plans/R2-08-ldap-historyread-async-plan.md.tasks.json @@ -1,7 +1,7 @@ { "planPath": "archreview/plans/R2-08-ldap-historyread-async-plan.md", "tasks": [ - { "id": "R2-08-T1", "subject": "S2 stall-repro test (RED): slow LDAP backend must deny within AuthTimeout — fails on current code", "status": "pending", "blockedBy": [] }, + { "id": "R2-08-T1", "subject": "S2 stall-repro test (RED): slow LDAP backend must deny within AuthTimeout — fails on current code", "status": "completed", "blockedBy": [] }, { "id": "R2-08-T2", "subject": "LdapOptions: ConnectionTimeoutMs/AuthTimeoutMs/MaxConcurrentAuthentications/OutageFailureThreshold/OutageCooldownSeconds + ToLibraryOptions projection + validator rules", "status": "pending", "blockedBy": [] }, { "id": "R2-08-T3", "subject": "LdapAuthResult.IsSystemFailure seam + OtOpcUaLdapAuthService.Adapt mapping (ServiceAccountBindFailed => system-side)", "status": "pending", "blockedBy": [] }, { "id": "R2-08-T4", "subject": "LdapOpcUaUserAuthenticator: WaitAsync wall-clock bound + SemaphoreSlim concurrency cap with release-on-core-completion (turns T1 green)", "status": "pending", "blockedBy": ["R2-08-T1", "R2-08-T2"] }, diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapAuthResilienceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapAuthResilienceTests.cs new file mode 100644 index 00000000..d43fafd4 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapAuthResilienceTests.cs @@ -0,0 +1,104 @@ +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)); + } +}