feat(host): LDAP directory-outage circuit — fail-fast denial during sustained outage (03/S2)

This commit is contained in:
Joseph Doherty
2026-07-13 11:56:45 -04:00
parent 21abe42a90
commit 0c785415a2
3 changed files with 240 additions and 8 deletions
@@ -105,6 +105,118 @@ public sealed class LdapAuthResilienceTests
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");
}
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),
@@ -187,6 +299,34 @@ public sealed class LdapAuthResilienceTests
}
}
/// <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>
{