using ZB.MOM.WW.Auth.Abstractions.Ldap; namespace ZB.MOM.WW.Auth.Ldap.Tests; /// /// 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. /// 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())); 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); } }