feat(security): LDAP auth timeout/concurrency/outage-circuit options; project ConnectionTimeoutMs into the shared library (03/S2)

This commit is contained in:
Joseph Doherty
2026-07-13 11:38:37 -04:00
parent ce6d9e21d3
commit 09faa05f06
5 changed files with 197 additions and 1 deletions
@@ -61,6 +61,56 @@ public sealed class LdapOptionsBindingTests
options.DevStubMode.ShouldBeFalse();
}
/// <summary>
/// The five S2 resilience keys bind from the real <c>Security:Ldap</c> section (03/S2).
/// </summary>
[Fact]
public void Binding_reads_resilience_keys()
{
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();
var options = configuration.GetSection(LdapOptions.SectionName).Get<LdapOptions>();
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>
[Fact]
public void Resilience_key_defaults_hold_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);
}
/// <summary><c>ConnectionTimeoutMs</c> is projected onto the shared-library options so the
/// socket-connect + per-search timeout is bounded inside the directory client.</summary>
[Fact]
public void ToLibraryOptions_projects_ConnectionTimeoutMs()
{
var options = new LdapOptions { ConnectionTimeoutMs = 4321 };
options.ToLibraryOptions().ConnectionTimeoutMs.ShouldBe(4321);
}
}
/// <summary>
@@ -203,6 +203,95 @@ public sealed class LdapOptionsValidatorTests
result.Failures.ShouldContain("Ldap:SearchBase is required when LDAP login is enabled.");
}
/// <summary>Enabled with a non-positive <c>ConnectionTimeoutMs</c> fails validation (03/S2).</summary>
[Fact]
public void Enabled_with_nonpositive_ConnectionTimeoutMs_fails()
{
var options = new LdapOptions
{
Enabled = true,
Server = "ldap",
SearchBase = "dc=x",
Port = 389,
Transport = LdapTransport.Ldaps,
ConnectionTimeoutMs = 0,
};
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()