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));
+ }
+}