238 lines
9.6 KiB
C#
238 lines
9.6 KiB
C#
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
|
|
|
namespace ZB.MOM.WW.Auth.Ldap.Tests;
|
|
|
|
/// <summary>
|
|
/// Covers the multi-endpoint failover walk: which failures fail over (system-side only),
|
|
/// which are final (any authoritative answer from a healthy directory), how the sticky
|
|
/// endpoint preference moves, and that single-endpoint behaviour is unchanged.
|
|
/// </summary>
|
|
public class LdapAuthServiceFailoverTests
|
|
{
|
|
private static LdapOptions Opts(params string[] fallbacks) => new()
|
|
{
|
|
Enabled = true,
|
|
Server = "dc1",
|
|
Port = 636,
|
|
Transport = LdapTransport.Ldaps,
|
|
SearchBase = "dc=x",
|
|
ServiceAccountDn = "cn=svc,dc=x",
|
|
ServiceAccountPassword = "svcpw",
|
|
FallbackServers = fallbacks,
|
|
};
|
|
|
|
private static FakeLdapConnection HealthyWithUser() =>
|
|
new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=ops,dc=x" });
|
|
|
|
// ---- failover triggers (system-side) ----
|
|
|
|
[Fact]
|
|
public async Task ConnectFailure_FailsOverToFallback_AndSucceeds()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
new FakeLdapConnection().ThrowOnConnect(), HealthyWithUser());
|
|
var svc = new LdapAuthService(Opts("dc2:3894"), factory);
|
|
|
|
var result = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
|
|
|
Assert.True(result.Succeeded);
|
|
Assert.Equal(2, factory.Created.Count);
|
|
Assert.Equal("dc1", factory.Created[0].ConnectArgs!.Value.Host);
|
|
Assert.Equal("dc2", factory.Created[1].ConnectArgs!.Value.Host);
|
|
Assert.Equal(3894, factory.Created[1].ConnectArgs!.Value.Port);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ServiceBindFailure_FailsOverToFallback()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
new FakeLdapConnection().ThrowOnServiceBind(), HealthyWithUser());
|
|
var svc = new LdapAuthService(Opts("dc2"), factory);
|
|
|
|
var result = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
|
|
|
Assert.True(result.Succeeded);
|
|
Assert.Equal(636, factory.Created[1].ConnectArgs!.Value.Port); // bare host adopts Port
|
|
}
|
|
|
|
// ---- user-side results NEVER fail over ----
|
|
|
|
[Fact]
|
|
public async Task BadUserCredentials_DoesNotFailOver()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
HealthyWithUser().ThrowOnUserBind());
|
|
var svc = new LdapAuthService(Opts("dc2"), factory);
|
|
|
|
var result = await svc.AuthenticateAsync("alice", "wrong", CancellationToken.None);
|
|
|
|
Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
|
|
Assert.Single(factory.Created);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UserNotFound_DoesNotFailOver()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection());
|
|
var svc = new LdapAuthService(Opts("dc2"), factory);
|
|
|
|
var result = await svc.AuthenticateAsync("ghost", "pw", CancellationToken.None);
|
|
|
|
Assert.Equal(LdapAuthFailure.UserNotFound, result.Failure);
|
|
Assert.Single(factory.Created);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GroupLookupFailed_DoesNotFailOver()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", Array.Empty<string>()));
|
|
var svc = new LdapAuthService(Opts("dc2"), factory);
|
|
|
|
var result = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
|
|
|
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
|
|
Assert.Single(factory.Created);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AmbiguousUser_DoesNotFailOver()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
new FakeLdapConnection().WithDuplicateMatch());
|
|
var svc = new LdapAuthService(Opts("dc2"), factory);
|
|
|
|
var result = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
|
|
|
Assert.Equal(LdapAuthFailure.AmbiguousUser, result.Failure);
|
|
Assert.Single(factory.Created);
|
|
}
|
|
|
|
// ---- exhaustion ----
|
|
|
|
[Fact]
|
|
public async Task AllEndpointsDown_ReturnsServiceAccountBindFailed()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
new FakeLdapConnection().ThrowOnConnect(),
|
|
new FakeLdapConnection().ThrowOnConnect());
|
|
var svc = new LdapAuthService(Opts("dc2"), factory);
|
|
|
|
var result = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
|
|
|
Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, result.Failure);
|
|
Assert.Equal(2, factory.Created.Count);
|
|
}
|
|
|
|
// ---- stickiness ----
|
|
|
|
[Fact]
|
|
public async Task StickyPreference_SecondLoginStartsAtLastGoodEndpoint()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
new FakeLdapConnection().ThrowOnConnect(), // login 1 @ dc1: dead
|
|
HealthyWithUser(), // login 1 @ dc2: answers
|
|
HealthyWithUser()); // login 2: should go straight to dc2
|
|
var svc = new LdapAuthService(Opts("dc2"), factory);
|
|
|
|
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
|
|
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
|
|
|
|
Assert.Equal(3, factory.Created.Count);
|
|
Assert.Equal("dc2", factory.Created[2].ConnectArgs!.Value.Host);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StickyPreference_DriftsBackToPrimary_WhenPreferredFails()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
new FakeLdapConnection().ThrowOnConnect(), // login 1 @ dc1: dead
|
|
HealthyWithUser(), // login 1 @ dc2: answers -> preferred
|
|
new FakeLdapConnection().ThrowOnConnect(), // login 2 @ dc2: now dead
|
|
HealthyWithUser(), // login 2 @ dc1 (declared order): recovered
|
|
HealthyWithUser()); // login 3: should start at dc1 again
|
|
var svc = new LdapAuthService(Opts("dc2"), factory);
|
|
|
|
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
|
|
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
|
|
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
|
|
|
|
Assert.Equal(5, factory.Created.Count);
|
|
Assert.Equal("dc1", factory.Created[3].ConnectArgs!.Value.Host);
|
|
Assert.Equal("dc1", factory.Created[4].ConnectArgs!.Value.Host);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StickyPreference_UpdatesOnUserSideAnswer_NotOnlySuccess()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
new FakeLdapConnection().ThrowOnConnect(), // login 1 @ dc1: dead
|
|
new FakeLdapConnection(), // login 1 @ dc2: answers UserNotFound
|
|
HealthyWithUser()); // login 2: should start at dc2
|
|
var svc = new LdapAuthService(Opts("dc2"), factory);
|
|
|
|
var first = await svc.AuthenticateAsync("ghost", "pw", CancellationToken.None);
|
|
Assert.Equal(LdapAuthFailure.UserNotFound, first.Failure);
|
|
|
|
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
|
|
Assert.Equal("dc2", factory.Created[2].ConnectArgs!.Value.Host);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AllEndpointsDown_LeavesPreferenceUnchanged()
|
|
{
|
|
// A total outage teaches nothing about which endpoint is healthy, so the walk
|
|
// must not move the preference — the next login still starts at the primary.
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
new FakeLdapConnection().ThrowOnConnect(), // login 1 @ dc1
|
|
new FakeLdapConnection().ThrowOnConnect(), // login 1 @ dc2
|
|
HealthyWithUser()); // login 2: must start at dc1 again
|
|
var svc = new LdapAuthService(Opts("dc2"), factory);
|
|
|
|
Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed,
|
|
(await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Failure);
|
|
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
|
|
|
|
Assert.Equal("dc1", factory.Created[2].ConnectArgs!.Value.Host);
|
|
}
|
|
|
|
// ---- back-compat ----
|
|
|
|
[Fact]
|
|
public async Task NoFallbacks_SingleAttempt_UnchangedBehavior()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
new FakeLdapConnection().ThrowOnConnect());
|
|
var svc = new LdapAuthService(Opts(/* no fallbacks */), factory);
|
|
|
|
var result = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
|
|
|
Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, result.Failure);
|
|
Assert.Single(factory.Created);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Disabled_NeverOpensAConnection_EvenWithFallbacks()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(HealthyWithUser());
|
|
var svc = new LdapAuthService(Opts("dc2") with { Enabled = false }, factory);
|
|
|
|
var result = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
|
|
|
Assert.Equal(LdapAuthFailure.Disabled, result.Failure);
|
|
Assert.Empty(factory.Created);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MalformedFallbackEntry_IsSkippedDefensively()
|
|
{
|
|
// The validator rejects this at boot; a service constructed without validation
|
|
// must degrade to the parseable subset, not throw.
|
|
var factory = new SequenceLdapConnectionFactory(HealthyWithUser());
|
|
var svc = new LdapAuthService(Opts(":::"), factory);
|
|
|
|
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
|
|
Assert.Single(factory.Created);
|
|
}
|
|
}
|