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); } // ---- observability ---- /// Captures emitted log entries so failover visibility can be asserted. private sealed class RecordingLogger : Microsoft.Extensions.Logging.ILogger { public List<(Microsoft.Extensions.Logging.LogLevel Level, string Message)> Entries { get; } = new(); public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true; public void Log(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId, TState state, Exception? exception, Func formatter) => Entries.Add((logLevel, formatter(state, exception))); } [Fact] public async Task Failover_LogsWarningWithEndpointAndStage_AndInfoOnStickyChange() { var logger = new RecordingLogger(); var factory = new SequenceLdapConnectionFactory( new FakeLdapConnection().ThrowOnConnect(), HealthyWithUser()); var svc = new LdapAuthService(Opts("dc2:3894"), factory, logger); await svc.AuthenticateAsync("alice", "pw", CancellationToken.None); Assert.Contains(logger.Entries, e => e.Level == Microsoft.Extensions.Logging.LogLevel.Warning && e.Message.Contains("dc1:636") && e.Message.Contains("connect")); Assert.Contains(logger.Entries, e => e.Level == Microsoft.Extensions.Logging.LogLevel.Information && e.Message.Contains("dc2:3894")); // Never log secrets: Assert.DoesNotContain(logger.Entries, e => e.Message.Contains("svcpw") || e.Message.Contains("pw\"")); } [Fact] public async Task HappyPath_LogsNothing() { var logger = new RecordingLogger(); var factory = new SequenceLdapConnectionFactory(HealthyWithUser()); var svc = new LdapAuthService(Opts("dc2"), factory, logger); await svc.AuthenticateAsync("alice", "pw", CancellationToken.None); Assert.Empty(logger.Entries); } [Fact] public async Task ServiceBindFailure_LogsTheServiceBindStage_NotConnect() { var logger = new RecordingLogger(); var factory = new SequenceLdapConnectionFactory( new FakeLdapConnection().ThrowOnServiceBind(), HealthyWithUser()); var svc = new LdapAuthService(Opts("dc2"), factory, logger); await svc.AuthenticateAsync("alice", "pw", CancellationToken.None); Assert.Contains(logger.Entries, e => e.Level == Microsoft.Extensions.Logging.LogLevel.Warning && e.Message.Contains("dc1:636") && e.Message.Contains("service-bind")); } }