test(host): red repro — LDAP authenticator has no wall-clock bound on session activation (03/S2)
This commit is contained in:
@@ -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"] },
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Resilience guard for <see cref="LdapOpcUaUserAuthenticator"/> (arch-review 03/S2). The OPC UA SDK
|
||||
/// raises the impersonation callback synchronously under a <c>SessionManager</c>-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.
|
||||
/// </summary>
|
||||
public sealed class LdapAuthResilienceTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<string>(), null));
|
||||
var mapper = new FakeMapper(g => g.ToArray());
|
||||
var sut = new LdapOpcUaUserAuthenticator(
|
||||
ldap,
|
||||
ScopeFactoryWith(mapper),
|
||||
NullLogger<LdapOpcUaUserAuthenticator>.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));
|
||||
}
|
||||
|
||||
/// <summary>Builds an IServiceScopeFactory whose scopes resolve the supplied mapper.</summary>
|
||||
private static IServiceScopeFactory ScopeFactoryWith(IGroupRoleMapper<string> mapper)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped(_ => mapper);
|
||||
return services.BuildServiceProvider().GetRequiredService<IServiceScopeFactory>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A fake <see cref="ILdapAuthService"/> 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.
|
||||
/// </summary>
|
||||
private sealed class DelayingFakeLdap : ILdapAuthService
|
||||
{
|
||||
private readonly TimeSpan _delay;
|
||||
private readonly LdapAuthResult _then;
|
||||
private int _calls;
|
||||
|
||||
/// <summary>Initializes the fake with a per-call delay and the result to return afterwards.</summary>
|
||||
/// <param name="delay">How long each authenticate call blocks before returning.</param>
|
||||
/// <param name="then">The result returned once the delay elapses.</param>
|
||||
public DelayingFakeLdap(TimeSpan delay, LdapAuthResult then)
|
||||
{
|
||||
_delay = delay;
|
||||
_then = then;
|
||||
}
|
||||
|
||||
/// <summary>The number of times the backend was actually invoked.</summary>
|
||||
public int Calls => Volatile.Read(ref _calls);
|
||||
|
||||
/// <summary>Awaits the configured delay, then returns the canned result.</summary>
|
||||
/// <param name="username">The username (ignored).</param>
|
||||
/// <param name="password">The password (ignored).</param>
|
||||
/// <param name="ct">Cancellation token, observed by the delay.</param>
|
||||
public async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
||||
{
|
||||
Interlocked.Increment(ref _calls);
|
||||
await Task.Delay(_delay, ct).ConfigureAwait(false);
|
||||
return _then;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Test group→role mapper driven by a delegate over the supplied groups.</summary>
|
||||
private sealed class FakeMapper(Func<IReadOnlyList<string>, IReadOnlyList<string>> map) : IGroupRoleMapper<string>
|
||||
{
|
||||
/// <summary>Maps groups to roles via the configured delegate; Scope is always null.</summary>
|
||||
/// <param name="groups">The LDAP groups to map.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public Task<GroupRoleMapping<string>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct)
|
||||
=> Task.FromResult(new GroupRoleMapping<string>(map(groups), Scope: null));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user