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