using System.Net.Security; using ZB.MOM.WW.Auth.Abstractions.Ldap; using ZB.MOM.WW.Auth.Ldap; namespace ZB.MOM.WW.Auth.Ldap.Tests; public class LdapAuthServiceTests { // Sensible test defaults: insecure plaintext transport (dev/test), a service // account set, and DisplayNameAttribute aligned with the fake's "displayName" // key so display-name extraction is genuinely exercised. 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 Succeeds_AndReturnsStrippedGroups_OnValidCredentials() { var fake = new FakeLdapConnection().WithUserEntry( "cn=alice,dc=x", memberOf: new[] { "cn=Engineers,ou=g,dc=x", "cn=Viewers,ou=g,dc=x" }, displayName: "Alice"); var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake)); var r = await svc.AuthenticateAsync(" alice ", "pw", default); Assert.True(r.Succeeded); Assert.Equal("alice", r.Username); // trimmed Assert.Equal("Alice", r.DisplayName); // from DisplayNameAttribute Assert.Equal(new[] { "Engineers", "Viewers" }, r.Groups); // CN= stripped } [Fact] public async Task BindsServiceAccountThenUser_OnValidCredentials() { // Non-empty memberOf: fail-closed requires at least one group for success, and this // test asserts bind ORDER, so the user must successfully resolve and bind. var fake = new FakeLdapConnection().WithUserEntry( "cn=alice,dc=x", memberOf: new[] { "cn=Engineers,ou=g,dc=x" }, displayName: "Alice"); var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake)); await svc.AuthenticateAsync("alice", "pw", default); // Service account first, user DN second (bind-then-search-then-bind). Assert.Equal(new[] { "cn=svc,dc=x", "cn=alice,dc=x" }, fake.BoundDns); } [Fact] public async Task FallsBackToUsername_WhenNoDisplayName() { // Non-empty memberOf so fail-closed lets success through; this test only asserts the // display-name fallback (no displayName attribute -> username). var fake = new FakeLdapConnection().WithUserEntry( "cn=bob,dc=x", memberOf: new[] { "cn=Viewers,ou=g,dc=x" }); var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake)); var r = await svc.AuthenticateAsync("bob", "pw", default); Assert.True(r.Succeeded); Assert.Equal("bob", r.DisplayName); } [Fact] public async Task Fails_Disabled_WhenNotEnabled() { var svc = new LdapAuthService( Opts() with { Enabled = false }, new FakeLdapConnectionFactory(new FakeLdapConnection())); Assert.Equal(LdapAuthFailure.Disabled, (await svc.AuthenticateAsync("a", "b", default)).Failure); } // --- Auth-006: TLS validation seam — allowInsecure is honoured and a cert-validation // callback is threaded into the connection rather than being silently ignored. --- [Fact] public async Task Connect_ReceivesAllowInsecureFlag_FromOptions() { // The allowInsecure flag must reach the connection (it used to be an unused parameter). var fake = new FakeLdapConnection().WithUserEntry( "cn=alice,dc=x", memberOf: new[] { "cn=Engineers,ou=g,dc=x" }); var svc = new LdapAuthService( Opts() with { AllowInsecure = true }, new FakeLdapConnectionFactory(fake)); await svc.AuthenticateAsync("alice", "pw", default); Assert.NotNull(fake.ConnectArgs); Assert.True(fake.ConnectArgs!.Value.AllowInsecure); } [Fact] public async Task Connect_ReceivesConfiguredCertValidationCallback() { // A consumer-supplied RemoteCertificateValidationCallback must be passed through to the // connection so production callers can pin a CA / validate the SAN — the seam no longer // discards it. RemoteCertificateValidationCallback callback = (_, _, _, _) => true; var fake = new FakeLdapConnection().WithUserEntry( "cn=alice,dc=x", memberOf: new[] { "cn=Engineers,ou=g,dc=x" }); var svc = new LdapAuthService( Opts() with { ServerCertificateValidationCallback = callback }, new FakeLdapConnectionFactory(fake)); await svc.AuthenticateAsync("alice", "pw", default); Assert.Same(callback, fake.ConnectCertCallback); } [Fact] public async Task Connect_NoCertCallbackConfigured_PassesNull() { // Default: no callback configured -> null reaches the connection, which means the // production adapter falls back to OS-trust-store validation (documented behaviour). var fake = new FakeLdapConnection().WithUserEntry( "cn=alice,dc=x", memberOf: new[] { "cn=Engineers,ou=g,dc=x" }); var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake)); await svc.AuthenticateAsync("alice", "pw", default); Assert.Null(fake.ConnectCertCallback); } [Fact] public async Task PreservesEscapedCommaInGroupName_OnRfc4514Dn() { // C1: a group CN that legitimately contains a comma (escaped per RFC 4514) // must be returned intact, not truncated at the escaped comma. var fake = new FakeLdapConnection().WithUserEntry( "cn=alice,dc=x", memberOf: new[] { @"cn=Eng\,ineers,ou=g,dc=x", @"cn=A\2cB,dc=x" }, displayName: "Alice"); var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake)); var r = await svc.AuthenticateAsync("alice", "pw", default); Assert.True(r.Succeeded); Assert.Equal(new[] { "Eng,ineers", "A,B" }, r.Groups); } }