refactor(acl): delete the LDAP data-plane authenticator and its tests (#520)
Stage 2b. LdapOpcUaUserAuthenticator was left registered nowhere by stage 2a; this removes it, its three test files, and the config surface that existed only to serve it. Deleted: LdapOpcUaUserAuthenticator, LdapOpcUaUserAuthenticatorTests, LdapAuthResilienceTests, LdapGlauthOutageLiveTests (19 tests). Also removed four Security:Ldap keys — AuthTimeoutMs, MaxConcurrentAuthentications, OutageFailureThreshold, OutageCooldownSeconds — plus their validator rules and binding/validator test coverage. Verified first that the ONLY consumers were the deleted class and the validator rules asserting them; none appear in any checked-in appsettings. Leaving them would have created exactly the dead-config surface #521/#524/#525 were filed to clear up. Worth stating why that resilience machinery does not need relocating rather than just deleting. It existed because the OPC UA SDK raises impersonation SYNCHRONOUSLY under a SessionManager-wide event lock, so a hung LDAP bind there serialized every session activation server-wide — Anonymous and X509 included. Key verification is one indexed lookup plus one HMAC with no network I/O, so the hazard the wall-clock bound, concurrency cap and outage circuit guarded against no longer exists on that path. LDAP now runs only on AdminUI login, an ordinary async request path where a slow directory affects the one request making it. ConnectionTimeoutMs is RETAINED: it is projected into the shared library and is not authenticator-specific. Docs corrected rather than left to rot: - CLAUDE.md's "LDAP Authentication" section described the deleted file as the data-plane authenticator. Rewritten, including a warning not to "restore" it on seeing the LDAP wiring that AdminUI legitimately still needs. - infra/README §5b (the GLAuth outage live gate) marked RETIRED with the reason, rather than silently dropped — the harness GLAuth is still used by OTOPCUA_HARNESS_USE_LDAP=1 and is unaffected. - docs/security.md's five-key resilience table cut to the one surviving key, and a new API-key identity section added. Historical archreview/ records are left as-is; they are dated accounts. Host.IntegrationTests 211 tests (was 230, i.e. exactly the 19 removed) with the same 3 pre-existing failures verified against clean master. Host 37, Security 89. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
@@ -1,389 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
using LdapTransport = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport;
|
||||
|
||||
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 = 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));
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
/// <summary>After <c>OutageFailureThreshold</c> consecutive system-side failures the circuit opens and
|
||||
/// the next call is fast-denied "backend unavailable" WITHOUT touching LDAP (03/S2).</summary>
|
||||
[Fact]
|
||||
public async Task Consecutive_system_failures_open_circuit_and_fast_deny()
|
||||
{
|
||||
var ldap = new DelayingFakeLdap(TimeSpan.Zero, SystemFailure());
|
||||
var mapper = new FakeMapper(g => g.ToArray());
|
||||
var sut = Build(ldap, mapper,
|
||||
new LdapOptions { OutageFailureThreshold = 3, OutageCooldownSeconds = 60, AuthTimeoutMs = 5000 });
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var r = await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
|
||||
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
|
||||
r.Success.ShouldBeFalse();
|
||||
}
|
||||
|
||||
ldap.Calls.ShouldBe(3);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var blocked = await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
|
||||
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
|
||||
sw.Stop();
|
||||
|
||||
blocked.Success.ShouldBeFalse();
|
||||
blocked.Error.ShouldNotBeNull();
|
||||
blocked.Error!.ShouldContain("unavailable");
|
||||
ldap.Calls.ShouldBe(3, "an open circuit must fast-deny without reaching the backend");
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
/// <summary>A user-side (credential) deny between system failures resets the streak, so the circuit
|
||||
/// never opens — user denies are not directory evidence (03/S2).</summary>
|
||||
[Fact]
|
||||
public async Task User_side_deny_resets_the_system_failure_streak()
|
||||
{
|
||||
var ldap = new ScriptedFakeLdap(
|
||||
SystemFailure(), SystemFailure(), UserDeny(), SystemFailure(), SystemFailure());
|
||||
var mapper = new FakeMapper(g => g.ToArray());
|
||||
var sut = Build(ldap, mapper,
|
||||
new LdapOptions { OutageFailureThreshold = 3, OutageCooldownSeconds = 60, AuthTimeoutMs = 5000 });
|
||||
|
||||
// 5 scripted calls: sys, sys, user(reset), sys, sys → streak ends at 2 (< 3), circuit stays closed.
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
|
||||
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
|
||||
}
|
||||
|
||||
// A 6th call must still REACH the backend (circuit never opened).
|
||||
await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
|
||||
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
|
||||
ldap.Calls.ShouldBe(6);
|
||||
}
|
||||
|
||||
/// <summary>After the cooldown elapses the circuit half-opens: the next call probes the backend and a
|
||||
/// success closes the circuit (03/S2).</summary>
|
||||
[Fact]
|
||||
public async Task Half_open_probe_after_cooldown_closes_on_success()
|
||||
{
|
||||
var ldap = new ScriptedFakeLdap(
|
||||
SystemFailure(), SystemFailure(), SystemFailure(),
|
||||
new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty<string>(), null));
|
||||
var mapper = new FakeMapper(g => g.ToArray());
|
||||
var sut = Build(ldap, mapper,
|
||||
new LdapOptions { OutageFailureThreshold = 3, OutageCooldownSeconds = 1, AuthTimeoutMs = 5000 });
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
|
||||
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
|
||||
ldap.Calls.ShouldBe(3);
|
||||
|
||||
// Circuit open: this call is fast-denied without touching the backend.
|
||||
var blocked = await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
|
||||
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
|
||||
blocked.Error!.ShouldContain("unavailable");
|
||||
ldap.Calls.ShouldBe(3);
|
||||
|
||||
// Wait past the cooldown, then a probe reaches the backend and succeeds → circuit closes.
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(1200), TestContext.Current.CancellationToken);
|
||||
|
||||
var probe = await sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None)
|
||||
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
|
||||
probe.Success.ShouldBeTrue();
|
||||
ldap.Calls.ShouldBe(4, "the half-open probe reaches the backend");
|
||||
}
|
||||
|
||||
/// <summary><c>OutageFailureThreshold = 0</c> disables the circuit — it never opens, every call reaches
|
||||
/// the backend (03/S2).</summary>
|
||||
[Fact]
|
||||
public async Task Circuit_disabled_never_opens()
|
||||
{
|
||||
var ldap = new DelayingFakeLdap(TimeSpan.Zero, SystemFailure());
|
||||
var mapper = new FakeMapper(g => g.ToArray());
|
||||
var sut = Build(ldap, mapper,
|
||||
new LdapOptions { OutageFailureThreshold = 0, OutageCooldownSeconds = 60, AuthTimeoutMs = 5000 });
|
||||
|
||||
for (var i = 0; i < 6; i++)
|
||||
await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
|
||||
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
|
||||
|
||||
ldap.Calls.ShouldBe(6, "a disabled circuit never fast-denies");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outage-sim (offline-safe): the REAL <see cref="OtOpcUaLdapAuthService"/> + real shared library
|
||||
/// pointed at an UNROUTABLE host (a TCP blackhole — the same outage shape as "stop the GLAuth
|
||||
/// container"), driven through the REAL internal <see cref="OpcUaApplicationHost.HandleImpersonation"/>
|
||||
/// block-bridge. The activation must fail bounded (well under the SDK's default ~10-20 s stall) with
|
||||
/// <see cref="StatusCodes.BadIdentityTokenRejected"/> and no granted identity (03/S2).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Activation_with_unreachable_directory_fails_within_bound()
|
||||
{
|
||||
var options = new LdapOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DevStubMode = false,
|
||||
Server = "10.255.255.1", // RFC-blackhole: SYN goes nowhere
|
||||
Port = 3893,
|
||||
Transport = LdapTransport.None,
|
||||
AllowInsecure = true,
|
||||
SearchBase = "dc=zb,dc=local",
|
||||
ConnectionTimeoutMs = 500,
|
||||
AuthTimeoutMs = 1000,
|
||||
};
|
||||
|
||||
var ldap = new OtOpcUaLdapAuthService(Options.Create(options), NullLogger<OtOpcUaLdapAuthService>.Instance);
|
||||
var mapper = new FakeMapper(g => g.ToArray());
|
||||
var authenticator = new LdapOpcUaUserAuthenticator(
|
||||
ldap, ScopeFactoryWith(mapper), Options.Create(options),
|
||||
NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var token = new UserNameIdentityToken { UserName = "alice", DecryptedPassword = Encoding.UTF8.GetBytes("secret") };
|
||||
var policy = new UserTokenPolicy(UserTokenType.UserName) { PolicyId = "username_basic256sha256" };
|
||||
var args = new ImpersonateEventArgs(token, policy, new EndpointDescription());
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
await Task.Run(() => OpcUaApplicationHost.HandleImpersonation(
|
||||
authenticator, args, NullLogger<object>.Instance), TestContext.Current.CancellationToken)
|
||||
.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
sw.Stop();
|
||||
|
||||
args.Identity.ShouldBeNull();
|
||||
args.IdentityValidationError.ShouldNotBeNull();
|
||||
args.IdentityValidationError.Code.ShouldBe(Opc.Ua.StatusCodes.BadIdentityTokenRejected);
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
private static LdapAuthResult SystemFailure() =>
|
||||
new(false, null, "u", Array.Empty<string>(), Array.Empty<string>(), "Unexpected authentication error")
|
||||
{ IsSystemFailure = true };
|
||||
|
||||
private static LdapAuthResult UserDeny() =>
|
||||
new(false, null, "u", Array.Empty<string>(), Array.Empty<string>(), "Invalid username or password")
|
||||
{ IsSystemFailure = false };
|
||||
|
||||
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)
|
||||
{
|
||||
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>
|
||||
/// 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>
|
||||
/// A fake <see cref="ILdapAuthService"/> that returns a scripted sequence of results (one per call;
|
||||
/// the last entry repeats once exhausted). Counts calls so the outage-circuit tests can assert the
|
||||
/// backend was — or was NOT — reached on a given call.
|
||||
/// </summary>
|
||||
private sealed class ScriptedFakeLdap : ILdapAuthService
|
||||
{
|
||||
private readonly IReadOnlyList<LdapAuthResult> _script;
|
||||
private int _calls;
|
||||
|
||||
/// <summary>Initializes the fake with the scripted results, returned in order.</summary>
|
||||
/// <param name="script">The results to return, one per call; the last repeats once exhausted.</param>
|
||||
public ScriptedFakeLdap(params LdapAuthResult[] script) => _script = script;
|
||||
|
||||
/// <summary>The number of times the backend was invoked.</summary>
|
||||
public int Calls => Volatile.Read(ref _calls);
|
||||
|
||||
/// <summary>Returns the next scripted result (clamped to the last entry).</summary>
|
||||
/// <param name="username">The username (ignored).</param>
|
||||
/// <param name="password">The password (ignored).</param>
|
||||
/// <param name="ct">The cancellation token (ignored).</param>
|
||||
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
||||
{
|
||||
var idx = Interlocked.Increment(ref _calls) - 1;
|
||||
return Task.FromResult(_script[Math.Min(idx, _script.Count - 1)]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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));
|
||||
}
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
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;
|
||||
using LdapTransport = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// R2-08 03/S2 LIVE gate — the directory-outage circuit exercised against a REAL GLAuth server
|
||||
/// (the image + config PR #451 swapped in for the retired bitnami/openldap), driven through the
|
||||
/// production auth path (<see cref="OtOpcUaLdapAuthService"/> + <see cref="LdapOpcUaUserAuthenticator"/>).
|
||||
/// The offline <see cref="LdapAuthResilienceTests"/> prove the circuit logic with fakes + an
|
||||
/// unroutable-host blackhole; this proves the same behaviour against an actual directory that is
|
||||
/// paused mid-run (a real outage) and unpaused (recovery), and doubles as the first end-to-end
|
||||
/// verification that the GLAuth swap binds correctly.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Triple-gated so it never runs by accident and always restores the shared GLAuth:
|
||||
/// <list type="number">
|
||||
/// <item><c>GLAUTH_LIVE_HOST</c> — the GLAuth host (e.g. <c>10.100.0.35</c>); absent ⇒ skip;</item>
|
||||
/// <item><c>GLAUTH_OUTAGE_START_CMD</c> — pauses the GLAuth container (a real outage); and</item>
|
||||
/// <item><c>GLAUTH_OUTAGE_STOP_CMD</c> — unpauses it.</item>
|
||||
/// </list>
|
||||
/// A <c>try/finally</c> ALWAYS runs the stop command once the pause succeeded. Operator recipe:
|
||||
/// <code>
|
||||
/// ssh dohertj2@10.100.0.35 "cd ~/otopcua-harness && docker compose up -d ldap"
|
||||
/// export GLAUTH_LIVE_HOST=10.100.0.35
|
||||
/// export GLAUTH_OUTAGE_START_CMD='ssh dohertj2@10.100.0.35 "docker pause otopcua-harness-ldap-1"'
|
||||
/// export GLAUTH_OUTAGE_STOP_CMD='ssh dohertj2@10.100.0.35 "docker unpause otopcua-harness-ldap-1"'
|
||||
/// dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~LdapGlauthOutageLive"
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Category", "LiveLdap")]
|
||||
public sealed class LdapGlauthOutageLiveTests
|
||||
{
|
||||
private const string HostEnvVar = "GLAUTH_LIVE_HOST";
|
||||
private const string PortEnvVar = "GLAUTH_LIVE_PORT";
|
||||
private const string StartCmdEnvVar = "GLAUTH_OUTAGE_START_CMD";
|
||||
private const string StopCmdEnvVar = "GLAUTH_OUTAGE_STOP_CMD";
|
||||
|
||||
private const int OutageThreshold = 3;
|
||||
private const int CooldownSeconds = 2;
|
||||
private static readonly TimeSpan AuthTimeout = TimeSpan.FromMilliseconds(2_500);
|
||||
|
||||
/// <summary>
|
||||
/// Healthy real binds succeed; pausing GLAuth makes consecutive binds fail bounded (never a
|
||||
/// multi-second SDK stall) and opens the circuit into a sub-second fast-deny; unpausing +
|
||||
/// cooldown lets a half-open probe close the circuit and binds succeed again.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Real_glauth_outage_opens_circuit_then_recovers_on_unpause()
|
||||
{
|
||||
var host = Environment.GetEnvironmentVariable(HostEnvVar);
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
Assert.Skip($"Set {HostEnvVar} (+ {StartCmdEnvVar}/{StopCmdEnvVar}) to run the live GLAuth outage cycle.");
|
||||
var port = int.TryParse(Environment.GetEnvironmentVariable(PortEnvVar), out var p) ? p : 3894;
|
||||
var startCmd = Environment.GetEnvironmentVariable(StartCmdEnvVar);
|
||||
var stopCmd = Environment.GetEnvironmentVariable(StopCmdEnvVar);
|
||||
if (string.IsNullOrWhiteSpace(startCmd) || string.IsNullOrWhiteSpace(stopCmd))
|
||||
Assert.Skip($"Set both {StartCmdEnvVar} and {StopCmdEnvVar} (docker pause/unpause) to run this destructive live test.");
|
||||
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var options = new LdapOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DevStubMode = false,
|
||||
Server = host!,
|
||||
Port = port,
|
||||
Transport = LdapTransport.None,
|
||||
AllowInsecure = true,
|
||||
SearchBase = "dc=zb,dc=local",
|
||||
ServiceAccountDn = "cn=serviceaccount,dc=zb,dc=local",
|
||||
ServiceAccountPassword = "serviceaccount123",
|
||||
ConnectionTimeoutMs = 2_000,
|
||||
AuthTimeoutMs = (int)AuthTimeout.TotalMilliseconds,
|
||||
OutageFailureThreshold = OutageThreshold,
|
||||
OutageCooldownSeconds = CooldownSeconds,
|
||||
};
|
||||
var authenticator = BuildAuthenticator(options);
|
||||
|
||||
// ── 1. Healthy directory: real binds succeed, wrong creds deny (user-side, not an outage). ──
|
||||
var alice = await authenticator.AuthenticateUserNameAsync("alice", "alice123", ct);
|
||||
alice.Success.ShouldBeTrue("alice should bind against the live GLAuth");
|
||||
alice.Roles.ShouldNotBeEmpty("a healthy bind returns alice's mapped group roles");
|
||||
|
||||
var bob = await authenticator.AuthenticateUserNameAsync("bob", "bob123", ct);
|
||||
bob.Success.ShouldBeTrue("bob should bind against the live GLAuth");
|
||||
|
||||
var wrong = await authenticator.AuthenticateUserNameAsync("alice", "wrong-password", ct);
|
||||
wrong.Success.ShouldBeFalse("a wrong password is a user-side deny");
|
||||
|
||||
// ── 2. Outage: pause GLAuth. Consecutive binds fail BOUNDED, then the circuit fast-denies. ──
|
||||
var paused = false;
|
||||
try
|
||||
{
|
||||
await RunShellCommandAsync(startCmd!, ct);
|
||||
paused = true;
|
||||
|
||||
for (var i = 0; i < OutageThreshold; i++)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var outage = await authenticator.AuthenticateUserNameAsync("alice", "alice123", ct);
|
||||
sw.Stop();
|
||||
|
||||
outage.Success.ShouldBeFalse($"bind #{i + 1} during the outage must fail closed");
|
||||
// The whole flow is wall-clock bounded — never the SDK's default ~10-20 s stall.
|
||||
sw.Elapsed.ShouldBeLessThan(AuthTimeout + TimeSpan.FromSeconds(2),
|
||||
"the authenticator must bound a hung directory call, not stall on it");
|
||||
}
|
||||
|
||||
// Circuit is now open — the next call fast-denies WITHOUT a directory round-trip (sub-second).
|
||||
var blockedSw = Stopwatch.StartNew();
|
||||
var blocked = await authenticator.AuthenticateUserNameAsync("alice", "alice123", ct);
|
||||
blockedSw.Stop();
|
||||
|
||||
blocked.Success.ShouldBeFalse();
|
||||
blocked.Error.ShouldNotBeNull();
|
||||
blocked.Error!.ShouldContain("unavailable", Case.Insensitive,
|
||||
"an open outage circuit fast-denies 'backend unavailable'");
|
||||
blockedSw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1),
|
||||
"an open circuit fast-denies without a directory call");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (paused)
|
||||
await RunShellCommandAsync(stopCmd!, CancellationToken.None); // ALWAYS restore GLAuth
|
||||
}
|
||||
|
||||
// ── 3. Recovery: after the cooldown the half-open probe reaches the (restored) directory. ──
|
||||
await Task.Delay(TimeSpan.FromSeconds(CooldownSeconds) + TimeSpan.FromSeconds(1), ct);
|
||||
|
||||
var recovered = false;
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
var probe = await authenticator.AuthenticateUserNameAsync("alice", "alice123", ct);
|
||||
if (probe.Success) { recovered = true; break; }
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(500), ct);
|
||||
}
|
||||
recovered.ShouldBeTrue("the circuit must half-open and close once GLAuth is unpaused");
|
||||
}
|
||||
|
||||
private static LdapOpcUaUserAuthenticator BuildAuthenticator(LdapOptions options)
|
||||
{
|
||||
var ldap = new OtOpcUaLdapAuthService(Options.Create(options), NullLogger<OtOpcUaLdapAuthService>.Instance);
|
||||
return new LdapOpcUaUserAuthenticator(
|
||||
ldap, ScopeFactoryWith(new PassthroughMapper()), Options.Create(options),
|
||||
NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
}
|
||||
|
||||
/// <summary>Builds an <see cref="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>Runs an SSH/docker one-liner through the login shell (verbatim, as in the S7 outage gate).</summary>
|
||||
private static async Task RunShellCommandAsync(string command, CancellationToken ct)
|
||||
{
|
||||
using var proc = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo("/bin/sh", $"-c \"{command.Replace("\"", "\\\"")}\"")
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
},
|
||||
};
|
||||
proc.Start();
|
||||
await proc.WaitForExitAsync(ct);
|
||||
proc.ExitCode.ShouldBe(0, $"outage command failed: {await proc.StandardError.ReadToEndAsync(ct)}");
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), ct); // let the pause/unpause take effect
|
||||
}
|
||||
|
||||
/// <summary>Passes LDAP groups straight through as roles — the live gate asserts on the bind, not the mapping.</summary>
|
||||
private sealed class PassthroughMapper : IGroupRoleMapper<string>
|
||||
{
|
||||
public Task<GroupRoleMapping<string>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct)
|
||||
=> Task.FromResult(new GroupRoleMapping<string>(groups, Scope: null));
|
||||
}
|
||||
}
|
||||
-152
@@ -1,152 +0,0 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="LdapOpcUaUserAuthenticator"/> translates app <see cref="ILdapAuthService"/>
|
||||
/// outcomes into <c>OpcUaUserAuthResult</c>, resolves roles from the directory's <em>groups</em>
|
||||
/// through the shared <see cref="IGroupRoleMapper{TRole}"/> seam (Task 1.2), unions any pre-resolved
|
||||
/// roles (the DevStub Administrator grant) in, and turns LDAP backend exceptions into a denial rather
|
||||
/// than letting them escape into the SDK.
|
||||
/// </summary>
|
||||
public sealed class LdapOpcUaUserAuthenticatorTests
|
||||
{
|
||||
/// <summary>On success the data-plane authenticator resolves roles via the mapper from the
|
||||
/// returned Groups — not from the auth result's Roles field — and grants identity.</summary>
|
||||
[Fact]
|
||||
public async Task Authenticate_LDAP_success_resolves_roles_via_mapper_from_groups()
|
||||
{
|
||||
// Library-style result: groups present, Roles empty (the real path). The mapper maps the
|
||||
// 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), Options.Create(new LdapOptions()), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.DisplayName.ShouldBe("Alice");
|
||||
result.Roles.ShouldBe(new[] { "Designer" });
|
||||
}
|
||||
|
||||
/// <summary>The DevStub pre-resolved roles (Administrator) survive the move to the mapper: they are
|
||||
/// unioned with the mapper output so the dev grant still reaches the OPC UA session.</summary>
|
||||
[Fact]
|
||||
public async Task Authenticate_devstub_preresolved_roles_are_unioned_with_mapper()
|
||||
{
|
||||
// DevStub-shaped result: group "dev", pre-resolved role "Administrator". Mapper maps "dev" to
|
||||
// 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), Options.Create(new LdapOptions()), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Roles.ShouldBe(new[] { "Administrator" });
|
||||
}
|
||||
|
||||
/// <summary>A mapper fault (e.g. DB outage) must not deny an authenticated session — it falls
|
||||
/// back to the pre-resolved roles, matching the login endpoint's behaviour.</summary>
|
||||
[Fact]
|
||||
public async Task Authenticate_mapper_fault_falls_back_to_preresolved_roles()
|
||||
{
|
||||
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), Options.Create(new LdapOptions()), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Roles.ShouldBe(new[] { "Administrator" });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that LDAP authentication failure returns Deny result with error text.</summary>
|
||||
[Fact]
|
||||
public async Task Authenticate_LDAP_failure_returns_Deny_with_error_text()
|
||||
{
|
||||
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), Options.Create(new LdapOptions()), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("mallory", "wrong", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Error.ShouldBe("Invalid username or password");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that LDAP exceptions are converted to backend error denial results.</summary>
|
||||
[Fact]
|
||||
public async Task Authenticate_LDAP_exception_returns_backend_error_denial()
|
||||
{
|
||||
var ldap = new FakeLdap(_ => throw new InvalidOperationException("LDAP unreachable"));
|
||||
var mapper = new FakeMapper(g => g.ToArray());
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), Options.Create(new LdapOptions()), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("anyone", "x", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Error.ShouldNotBeNull();
|
||||
result.Error.ShouldContain("backend");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that authentication falls back to username when LDAP omits display name.</summary>
|
||||
[Fact]
|
||||
public async Task Authenticate_falls_back_to_username_when_LDAP_omits_display_name()
|
||||
{
|
||||
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), Options.Create(new LdapOptions()), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("alice", "x", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.DisplayName.ShouldBe("alice");
|
||||
}
|
||||
|
||||
/// <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>Test fake implementation of LDAP authentication service.</summary>
|
||||
private sealed class FakeLdap : ILdapAuthService
|
||||
{
|
||||
private readonly Func<string, LdapAuthResult> _handler;
|
||||
|
||||
/// <summary>Initializes the fake with a fixed result that ignores the username.</summary>
|
||||
/// <param name="fixed_">The result to return for any authentication attempt.</param>
|
||||
public FakeLdap(LdapAuthResult fixed_) => _handler = _ => fixed_;
|
||||
|
||||
/// <summary>Initializes the fake with a handler function for custom results.</summary>
|
||||
/// <param name="handler">The handler to invoke with the username to produce a result.</param>
|
||||
public FakeLdap(Func<string, LdapAuthResult> handler) => _handler = handler;
|
||||
|
||||
/// <summary>Authenticates a user asynchronously via the handler function.</summary>
|
||||
/// <param name="username">The username to authenticate.</param>
|
||||
/// <param name="password">The password (ignored by the fake).</param>
|
||||
/// <param name="ct">Cancellation token for the operation.</param>
|
||||
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
||||
=> Task.FromResult(_handler(username));
|
||||
}
|
||||
|
||||
/// <summary>Test fake 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));
|
||||
}
|
||||
}
|
||||
@@ -63,19 +63,17 @@ public sealed class LdapOptionsBindingTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The five S2 resilience keys bind from the real <c>Security:Ldap</c> section (03/S2).
|
||||
/// <c>ConnectionTimeoutMs</c> binds from the real <c>Security:Ldap</c> section. The other four S2
|
||||
/// resilience keys were removed with <c>LdapOpcUaUserAuthenticator</c> in #520 — they bounded an
|
||||
/// LDAP bind running inline on OPC UA session activation, and no remaining consumer does that.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Binding_reads_resilience_keys()
|
||||
public void Binding_reads_the_connection_timeout_key()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Security:Ldap:ConnectionTimeoutMs"] = "7000",
|
||||
["Security:Ldap:AuthTimeoutMs"] = "12000",
|
||||
["Security:Ldap:MaxConcurrentAuthentications"] = "5",
|
||||
["Security:Ldap:OutageFailureThreshold"] = "4",
|
||||
["Security:Ldap:OutageCooldownSeconds"] = "20",
|
||||
})
|
||||
.Build();
|
||||
|
||||
@@ -83,23 +81,13 @@ public sealed class LdapOptionsBindingTests
|
||||
|
||||
options.ShouldNotBeNull();
|
||||
options.ConnectionTimeoutMs.ShouldBe(7000);
|
||||
options.AuthTimeoutMs.ShouldBe(12000);
|
||||
options.MaxConcurrentAuthentications.ShouldBe(5);
|
||||
options.OutageFailureThreshold.ShouldBe(4);
|
||||
options.OutageCooldownSeconds.ShouldBe(20);
|
||||
}
|
||||
|
||||
/// <summary>The resilience keys carry the documented defaults when absent from config.</summary>
|
||||
/// <summary>The connection timeout carries its documented default when absent from config.</summary>
|
||||
[Fact]
|
||||
public void Resilience_key_defaults_hold_when_absent()
|
||||
public void Connection_timeout_default_holds_when_absent()
|
||||
{
|
||||
var options = new LdapOptions();
|
||||
|
||||
options.ConnectionTimeoutMs.ShouldBe(10000);
|
||||
options.AuthTimeoutMs.ShouldBe(15000);
|
||||
options.MaxConcurrentAuthentications.ShouldBe(8);
|
||||
options.OutageFailureThreshold.ShouldBe(3);
|
||||
options.OutageCooldownSeconds.ShouldBe(15);
|
||||
new LdapOptions().ConnectionTimeoutMs.ShouldBe(10000);
|
||||
}
|
||||
|
||||
/// <summary><c>ConnectionTimeoutMs</c> is projected onto the shared-library options so the
|
||||
|
||||
@@ -220,79 +220,6 @@ public sealed class LdapOptionsValidatorTests
|
||||
Sut.Validate(null, options).Failed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Enabled with a non-positive <c>AuthTimeoutMs</c> fails validation (03/S2).</summary>
|
||||
[Fact]
|
||||
public void Enabled_with_nonpositive_AuthTimeoutMs_fails()
|
||||
{
|
||||
var options = new LdapOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Server = "ldap",
|
||||
SearchBase = "dc=x",
|
||||
Port = 389,
|
||||
Transport = LdapTransport.Ldaps,
|
||||
AuthTimeoutMs = 0,
|
||||
};
|
||||
|
||||
Sut.Validate(null, options).Failed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Enabled with a non-positive <c>MaxConcurrentAuthentications</c> fails validation (03/S2).</summary>
|
||||
[Fact]
|
||||
public void Enabled_with_nonpositive_MaxConcurrentAuthentications_fails()
|
||||
{
|
||||
var options = new LdapOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Server = "ldap",
|
||||
SearchBase = "dc=x",
|
||||
Port = 389,
|
||||
Transport = LdapTransport.Ldaps,
|
||||
MaxConcurrentAuthentications = 0,
|
||||
};
|
||||
|
||||
Sut.Validate(null, options).Failed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>A positive <c>OutageFailureThreshold</c> with a zero <c>OutageCooldownSeconds</c> fails —
|
||||
/// an open circuit with no cooldown would probe on every call (03/S2).</summary>
|
||||
[Fact]
|
||||
public void OutageFailureThreshold_positive_with_zero_cooldown_fails()
|
||||
{
|
||||
var options = new LdapOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Server = "ldap",
|
||||
SearchBase = "dc=x",
|
||||
Port = 389,
|
||||
Transport = LdapTransport.Ldaps,
|
||||
OutageFailureThreshold = 3,
|
||||
OutageCooldownSeconds = 0,
|
||||
};
|
||||
|
||||
Sut.Validate(null, options).Failed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>A zero <c>OutageFailureThreshold</c> (circuit disabled) passes regardless of the
|
||||
/// cooldown value (03/S2).</summary>
|
||||
[Fact]
|
||||
public void OutageFailureThreshold_zero_disables_circuit_and_passes()
|
||||
{
|
||||
var options = new LdapOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Server = "ldap",
|
||||
SearchBase = "dc=x",
|
||||
Port = 389,
|
||||
Transport = LdapTransport.Ldaps,
|
||||
OutageFailureThreshold = 0,
|
||||
OutageCooldownSeconds = 0,
|
||||
};
|
||||
|
||||
Sut.Validate(null, options).Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Enabled with port 0 reports the port-range failure using the shared primitive wording.</summary>
|
||||
[Fact]
|
||||
public void Enabled_with_zero_port_fails()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user