feat(host): hard wall-clock bound + bounded concurrency on OPC UA LDAP authentication (03/S2)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
@@ -32,10 +33,7 @@ public sealed class LdapAuthResilienceTests
|
||||
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 sut = Build(ldap, mapper, new LdapOptions { AuthTimeoutMs = 200 });
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var result = await sut
|
||||
@@ -49,6 +47,69 @@ public sealed class LdapAuthResilienceTests
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1.5));
|
||||
}
|
||||
|
||||
/// <summary>A fast, healthy backend is unaffected by the resilience wrapping — success + roles pass
|
||||
/// through untouched (03/S2).</summary>
|
||||
[Fact]
|
||||
public async Task Authenticate_fast_success_unaffected()
|
||||
{
|
||||
var ldap = new DelayingFakeLdap(
|
||||
TimeSpan.Zero,
|
||||
new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty<string>(), 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" });
|
||||
}
|
||||
|
||||
/// <summary>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).</summary>
|
||||
[Fact]
|
||||
public async Task Authenticate_saturated_concurrency_denies_busy()
|
||||
{
|
||||
var ldap = new GatedFakeLdap(
|
||||
new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty<string>(), 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<string> mapper, LdapOptions options) =>
|
||||
new(ldap, ScopeFactoryWith(mapper), Options.Create(options),
|
||||
NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
/// <summary>Builds an IServiceScopeFactory whose scopes resolve the supplied mapper.</summary>
|
||||
private static IServiceScopeFactory ScopeFactoryWith(IGroupRoleMapper<string> mapper)
|
||||
{
|
||||
@@ -92,6 +153,40 @@ public sealed class LdapAuthResilienceTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A fake <see cref="ILdapAuthService"/> that parks ASYNCHRONOUSLY (awaits a
|
||||
/// <see cref="TaskCompletionSource"/>) until the test calls <see cref="Release"/> — 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).
|
||||
/// </summary>
|
||||
private sealed class GatedFakeLdap : ILdapAuthService
|
||||
{
|
||||
private readonly TaskCompletionSource _park = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly LdapAuthResult _then;
|
||||
private int _entered;
|
||||
|
||||
/// <summary>Initializes the fake with the result to return once released.</summary>
|
||||
/// <param name="then">The result returned once <see cref="Release"/> is called.</param>
|
||||
public GatedFakeLdap(LdapAuthResult then) => _then = then;
|
||||
|
||||
/// <summary>Number of authenticate calls that reached the backend body.</summary>
|
||||
public int Entered => Volatile.Read(ref _entered);
|
||||
|
||||
/// <summary>Releases the parked bind so its awaiting call can complete.</summary>
|
||||
public void Release() => _park.TrySetResult();
|
||||
|
||||
/// <summary>Marks entry, awaits the parking gate, 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 while parked.</param>
|
||||
public async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
||||
{
|
||||
Interlocked.Increment(ref _entered);
|
||||
await _park.Task.WaitAsync(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>
|
||||
{
|
||||
|
||||
+7
-6
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
@@ -26,7 +27,7 @@ public sealed class LdapOpcUaUserAuthenticatorTests
|
||||
// group "configeditor" -> "Designer" (canonical, Task 1.7).
|
||||
var ldap = new FakeLdap(new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty<string>(), null));
|
||||
var mapper = new FakeMapper(g => g.Select(x => x == "configeditor" ? "Designer" : x).ToArray());
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), Options.Create(new LdapOptions()), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None);
|
||||
|
||||
@@ -44,7 +45,7 @@ public sealed class LdapOpcUaUserAuthenticatorTests
|
||||
// nothing, so the union is exactly {Administrator}.
|
||||
var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "Administrator" }, null));
|
||||
var mapper = new FakeMapper(_ => Array.Empty<string>());
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), Options.Create(new LdapOptions()), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None);
|
||||
|
||||
@@ -59,7 +60,7 @@ public sealed class LdapOpcUaUserAuthenticatorTests
|
||||
{
|
||||
var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "Administrator" }, null));
|
||||
var mapper = new FakeMapper(_ => throw new InvalidOperationException("DB down"));
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), Options.Create(new LdapOptions()), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None);
|
||||
|
||||
@@ -73,7 +74,7 @@ public sealed class LdapOpcUaUserAuthenticatorTests
|
||||
{
|
||||
var ldap = new FakeLdap(new LdapAuthResult(false, null, "mallory", Array.Empty<string>(), Array.Empty<string>(), "Invalid username or password"));
|
||||
var mapper = new FakeMapper(g => g.ToArray());
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), Options.Create(new LdapOptions()), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("mallory", "wrong", CancellationToken.None);
|
||||
|
||||
@@ -87,7 +88,7 @@ public sealed class LdapOpcUaUserAuthenticatorTests
|
||||
{
|
||||
var ldap = new FakeLdap(_ => throw new InvalidOperationException("LDAP unreachable"));
|
||||
var mapper = new FakeMapper(g => g.ToArray());
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), Options.Create(new LdapOptions()), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("anyone", "x", CancellationToken.None);
|
||||
|
||||
@@ -102,7 +103,7 @@ public sealed class LdapOpcUaUserAuthenticatorTests
|
||||
{
|
||||
var ldap = new FakeLdap(new LdapAuthResult(true, null, "alice", new[] { "ReadOnly" }, Array.Empty<string>(), null));
|
||||
var mapper = new FakeMapper(g => g.ToArray());
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), Options.Create(new LdapOptions()), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("alice", "x", CancellationToken.None);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user