using Novell.Directory.Ldap; using ZB.MOM.WW.Auth.Abstractions.Ldap; namespace ZB.MOM.WW.Auth.Ldap.Tests; /// /// : bind as the signing-in user, then read their own entry on the /// same connection. The service account in is deliberately populated so the /// tests prove it is never used. /// public class LdapAuthServiceUserBindTests { private static LdapOptions Opts(params string[] fallbacks) => new() { Enabled = true, Server = "dc1", Port = 636, Transport = LdapTransport.Ldaps, SearchBase = "dc=x", UserNameAttribute = "sAMAccountName", DisplayNameAttribute = "displayName", GroupAttribute = "memberOf", BindMode = LdapBindMode.User, UserBindTemplate = "{0}@corp.test", ServiceAccountDn = "cn=svc,dc=x", ServiceAccountPassword = "svcpw", FallbackServers = fallbacks, }; private static FakeLdapConnection Jane() => new FakeLdapConnection() .WithUserEntry("CN=Jane Doe,OU=u,DC=x", new[] { "CN=UG_A,OU=g,DC=x", "CN=UG_B,OU=g,DC=x" }, "Jane Doe"); private static Task Login(LdapAuthService svc, string user, string pw = "pw") => svc.AuthenticateAsync(user, pw, CancellationToken.None); [Fact] public async Task Success_BindsAsUser_ThenSearchesOnTheSameConnection() { var factory = new SequenceLdapConnectionFactory(Jane()); var result = await Login(new LdapAuthService(Opts(), factory), "jdoe"); Assert.True(result.Succeeded); Assert.Equal("jdoe", result.Username); Assert.Equal("Jane Doe", result.DisplayName); Assert.Equal(new[] { "UG_A", "UG_B" }, result.Groups); var conn = Assert.Single(factory.Created); Assert.Equal(new[] { "connect", "bind:jdoe@corp.test", "search:(sAMAccountName=jdoe)" }, conn.Calls); Assert.Equal(new[] { "jdoe@corp.test" }, conn.BoundDns); } [Theory] [InlineData(@"NAM\jdoe")] [InlineData("jdoe@corp.test")] [InlineData(" jdoe ")] public async Task QualifiedNames_BindAsTheTemplate_AndReturnTheBareName(string typed) { var factory = new SequenceLdapConnectionFactory(Jane()); var result = await Login(new LdapAuthService(Opts(), factory), typed); Assert.True(result.Succeeded); Assert.Equal("jdoe", result.Username); Assert.Equal(new[] { "jdoe@corp.test" }, factory.Created[0].BoundDns); } [Fact] public async Task EmptyPassword_IsRefused_WithoutContactingTheDirectory() { var factory = new SequenceLdapConnectionFactory(Jane()); var result = await Login(new LdapAuthService(Opts(), factory), "jdoe", ""); Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure); Assert.Empty(factory.Created); } [Theory] [InlineData("@x")] [InlineData(@"a\b\c")] [InlineData("a@b@c")] public async Task UnusableNames_AreRefused_WithoutContactingTheDirectory(string typed) { var factory = new SequenceLdapConnectionFactory(Jane()); var result = await Login(new LdapAuthService(Opts(), factory), typed); Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure); Assert.Empty(factory.Created); } [Fact] public async Task StrictMode_RefusesQualifiedNames_WithoutContactingTheDirectory() { var factory = new SequenceLdapConnectionFactory(Jane()); var svc = new LdapAuthService(Opts() with { AcceptDomainQualifiedNames = false }, factory); var result = await Login(svc, @"NAM\jdoe"); Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure); Assert.Empty(factory.Created); } [Fact] public async Task WrongPassword_IsFinal_AndNeverAsksTheFallback() { var factory = new SequenceLdapConnectionFactory( Jane().ThrowOnFirstBind(LdapException.InvalidCredentials), Jane()); var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe", "wrong"); Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure); Assert.Single(factory.Created); } [Fact] public async Task NonCredentialBindError_FailsOver() { var factory = new SequenceLdapConnectionFactory( Jane().ThrowOnFirstBind(LdapException.Unavailable), Jane()); var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe"); Assert.True(result.Succeeded); Assert.Equal(2, factory.Created.Count); Assert.Equal("dc2", factory.Created[1].ConnectArgs!.Value.Host); } [Fact] public async Task ConnectFailure_FailsOver() { var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().ThrowOnConnect(), Jane()); var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe"); Assert.True(result.Succeeded); Assert.Equal(2, factory.Created.Count); } [Fact] public async Task SearchFailure_FailsOver() { var factory = new SequenceLdapConnectionFactory(Jane().ThrowOnSearch(), Jane()); var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe"); Assert.True(result.Succeeded); Assert.Equal(2, factory.Created.Count); } [Fact] public async Task EveryEndpointDown_IsSystemSide() { var factory = new SequenceLdapConnectionFactory( new FakeLdapConnection().ThrowOnConnect(), new FakeLdapConnection().ThrowOnConnect()); var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe"); Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, result.Failure); } [Fact] public async Task NoEntryAfterAGoodBind_IsGroupLookupFailed() { var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithNoMatch()); var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe"); Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure); Assert.Single(factory.Created); } [Fact] public async Task TwoEntriesAfterAGoodBind_IsGroupLookupFailed() { var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithDuplicateMatch()); var result = await Login(new LdapAuthService(Opts(), factory), "jdoe"); Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure); } [Fact] public async Task NoGroups_IsGroupLookupFailed() { var factory = new SequenceLdapConnectionFactory( new FakeLdapConnection().WithUserEntry("CN=Jane,DC=x", Array.Empty(), "Jane")); var result = await Login(new LdapAuthService(Opts(), factory), "jdoe"); Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure); } [Fact] public async Task Username_IsCanonicalisedFromTheEntry_NotTheTypedCase() { var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithUserEntry( "CN=Jane Doe,OU=u,DC=x", new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe", new Dictionary { ["sAMAccountName"] = new[] { "jdoe" } })); var result = await Login(new LdapAuthService(Opts(), factory), @"NAM\JDOE"); Assert.True(result.Succeeded); Assert.Equal("jdoe", result.Username); } [Fact] public async Task Username_FallsBackToTheTypedName_WhenTheEntryLacksTheAttribute() { var factory = new SequenceLdapConnectionFactory(Jane()); var result = await Login(new LdapAuthService(Opts(), factory), "JDOE"); Assert.True(result.Succeeded); Assert.Equal("JDOE", result.Username); } [Fact] public async Task Search_RequestsTheUserNameAttribute() { var factory = new SequenceLdapConnectionFactory(Jane()); await Login(new LdapAuthService(Opts(), factory), "jdoe"); Assert.Equal(new[] { "displayName", "memberOf", "sAMAccountName" }, factory.Created[0].SearchAttributes); } [Fact] public async Task Search_DeDuplicatesTheUserNameAttribute_CaseInsensitively() { var factory = new SequenceLdapConnectionFactory(Jane()); var svc = new LdapAuthService(Opts() with { DisplayNameAttribute = "SAMACCOUNTNAME" }, factory); await Login(svc, "jdoe"); Assert.Equal(new[] { "SAMACCOUNTNAME", "memberOf" }, factory.Created[0].SearchAttributes); } }