using ZB.MOM.WW.Auth.Abstractions.Ldap; using ZB.MOM.WW.Auth.Ldap; namespace ZB.MOM.WW.Auth.Ldap.Tests; /// /// Task 6 failure-mode tests. These pin the fail-closed contract: every error path returns a /// structured , the method never throws, and /// a successful result always carries at least one group. /// public class LdapAuthServiceFailureTests { // Mirrors the happy-path test defaults (insecure plaintext dev transport, service account // set, DisplayNameAttribute aligned with the fake's "displayName" key). private static LdapOptions Opts() => new() { Enabled = true, Server = "x", Port = 3893, Transport = LdapTransport.None, AllowInsecure = true, SearchBase = "dc=x", ServiceAccountDn = "cn=svc,dc=x", ServiceAccountPassword = "svcpw", UserNameAttribute = "cn", DisplayNameAttribute = "displayName", GroupAttribute = "memberOf", }; [Fact] public async Task BadCredentials_WhenUserBindThrows() { var fake = new FakeLdapConnection() .WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" }) .ThrowOnUserBind(); var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake)) .AuthenticateAsync("alice", "bad", default); Assert.False(r.Succeeded); Assert.Equal(LdapAuthFailure.BadCredentials, r.Failure); } [Fact] public async Task UserNotFound_WhenZeroMatches() { var fake = new FakeLdapConnection().WithNoMatch(); var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake)) .AuthenticateAsync("ghost", "pw", default); Assert.False(r.Succeeded); Assert.Equal(LdapAuthFailure.UserNotFound, r.Failure); } [Fact] public async Task AmbiguousUser_WhenMultipleMatches() { var fake = new FakeLdapConnection().WithDuplicateMatch(); var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake)) .AuthenticateAsync("alice", "pw", default); Assert.False(r.Succeeded); Assert.Equal(LdapAuthFailure.AmbiguousUser, r.Failure); } [Fact] public async Task AmbiguousUser_DoesNotAttemptUserBind() { var fake = new FakeLdapConnection().WithDuplicateMatch(); await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake)) .AuthenticateAsync("alice", "pw", default); // Only the service-account bind should have happened; never bind an ambiguous DN. Assert.Equal(new[] { "cn=svc,dc=x" }, fake.BoundDns); } [Fact] public async Task GroupLookupFailed_WhenUserHasNoGroups() { var fake = new FakeLdapConnection() .WithUserEntry("cn=alice,dc=x", memberOf: Array.Empty()); var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake)) .AuthenticateAsync("alice", "pw", default); Assert.False(r.Succeeded); Assert.Equal(LdapAuthFailure.GroupLookupFailed, r.Failure); } [Fact] public async Task ServiceAccountBindFailed_Distinctly_WhenServiceBindThrows() { var fake = new FakeLdapConnection() .WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" }) .ThrowOnServiceBind(); var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake)) .AuthenticateAsync("alice", "pw", default); Assert.False(r.Succeeded); Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, r.Failure); // Distinct from BadCredentials: a service-account problem is a system misconfiguration, // not the end user's fault. Assert.NotEqual(LdapAuthFailure.BadCredentials, r.Failure); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public async Task BadCredentials_WhenUsernameNullOrWhitespace_NoConnectionAttempted(string? username) { // I4: an empty/whitespace/null username is rejected up front as BadCredentials, // before any connection or bind is attempted (and a null can't NRE into the catch-all). var fake = new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" }); var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake)) .AuthenticateAsync(username!, "pw", default); Assert.False(r.Succeeded); Assert.Equal(LdapAuthFailure.BadCredentials, r.Failure); Assert.Null(fake.ConnectArgs); // never connected Assert.Empty(fake.BoundDns); // never bound } [Fact] public async Task Throws_WhenCancellationRequested() { // I3: a pre-cancelled token is observed at entry, before any work. var fake = new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" }); var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake)); await Assert.ThrowsAnyAsync( () => svc.AuthenticateAsync("alice", "pw", new CancellationToken(canceled: true))); Assert.Null(fake.ConnectArgs); // never connected } [Fact] public async Task NeverThrows_OnConnectFailure() { var fake = new FakeLdapConnection() .WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" }) .ThrowOnConnect(); var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake)) .AuthenticateAsync("alice", "pw", default); Assert.False(r.Succeeded); // Directory unreachable is a system-side failure -> bucketed under ServiceAccountBindFailed. Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, r.Failure); } }