using System.Net.Security; using ZB.MOM.WW.Auth.Abstractions.Ldap; using ZB.MOM.WW.Auth.Ldap.Internal; /// /// Test double for . Script results and error /// conditions with the builder methods; inspect recorded calls via properties. /// Consumed by Task 5 (LdapAuthService) unit tests. /// internal sealed class FakeLdapConnection : ILdapConnection { // ---- scripted state ----- private readonly List _scriptedEntries = new(); private readonly HashSet _throwBindDns = new(StringComparer.OrdinalIgnoreCase); private bool _throwOnConnect; private bool _throwOnServiceBind; private bool _throwOnUserBind; // ---- observation ----- public (string Host, int Port, LdapTransport Transport, bool AllowInsecure, int TimeoutMs)? ConnectArgs { get; private set; } /// The server-certificate validation callback passed to the most recent call. public RemoteCertificateValidationCallback? ConnectCertCallback { get; private set; } public List BoundDns { get; } = new(); /// /// Count of attempts (including ones that throw). The first attempt is /// the service-account bind; the second is the user bind. Used to distinguish the two. /// public int BindAttempts { get; private set; } // ---- builder methods ----- /// /// Scripts a user entry that will be returned by the next call. /// Builds a minimal attribute bag with memberOf and optional displayName. /// public FakeLdapConnection WithUserEntry(string dn, string[] memberOf, string? displayName = null) { var attrs = new Dictionary>(StringComparer.OrdinalIgnoreCase) { ["memberOf"] = memberOf.ToList() }; if (displayName is not null) attrs["displayName"] = new[] { displayName }; _scriptedEntries.Add(new LdapSearchEntry(dn, attrs)); return this; } /// /// Configures the fake to throw when /// is called for (simulates bad credentials). /// public FakeLdapConnection ThrowOnBind(string dn) { _throwBindDns.Add(dn); return this; } /// /// Throw on the SECOND bind — the user /// re-bind in the bind-then-search-then-bind flow — to simulate bad user credentials. The /// first (service-account) bind still succeeds. Bind order, not DN, decides which one throws. /// public FakeLdapConnection ThrowOnUserBind() { _throwOnUserBind = true; return this; } /// /// Throw on the FIRST bind — the /// service-account bind — to simulate a service-account misconfiguration. Distinct from /// ; this fails before the directory search ever runs. /// public FakeLdapConnection ThrowOnServiceBind() { _throwOnServiceBind = true; return this; } /// /// Throw from to /// simulate an unreachable directory (infrastructure failure). /// public FakeLdapConnection ThrowOnConnect() { _throwOnConnect = true; return this; } /// /// Scripts a search that returns ZERO entries (no call also /// yields zero, but this states the intent explicitly). Simulates user-not-found. /// public FakeLdapConnection WithNoMatch() => this; /// /// Scripts a search that returns TWO entries for the username, simulating an ambiguous / /// non-unique match. Group/display-name content is irrelevant; only the count matters. /// public FakeLdapConnection WithDuplicateMatch() { WithUserEntry("cn=dup1,dc=x", new[] { "cn=g,dc=x" }); WithUserEntry("cn=dup2,dc=x", new[] { "cn=g,dc=x" }); return this; } // ---- ILdapConnection ----- public void Connect( string host, int port, LdapTransport transport, bool allowInsecure, int timeoutMs, RemoteCertificateValidationCallback? serverCertificateValidationCallback = null) { ConnectArgs = (host, port, transport, allowInsecure, timeoutMs); ConnectCertCallback = serverCertificateValidationCallback; if (_throwOnConnect) throw new Novell.Directory.Ldap.LdapException( "Directory unreachable", Novell.Directory.Ldap.LdapException.ConnectError, host); } public void Bind(string dn, string password) { BindAttempts++; var isServiceBind = BindAttempts == 1; if ((_throwOnServiceBind && isServiceBind) || (_throwOnUserBind && !isServiceBind) || _throwBindDns.Contains(dn)) { throw new Novell.Directory.Ldap.LdapException( "Invalid credentials", Novell.Directory.Ldap.LdapException.InvalidCredentials, dn); } BoundDns.Add(dn); } public IReadOnlyList Search( string searchBase, string filter, IReadOnlyList attributes) => _scriptedEntries.AsReadOnly(); public void Dispose() { /* nothing to clean up */ } } /// Factory that always returns the same pre-configured fake instance. internal sealed class FakeLdapConnectionFactory : ILdapConnectionFactory { /// Wraps a caller-supplied fake so a test can script it before handing it to the service. public FakeLdapConnectionFactory(FakeLdapConnection fake) => Fake = fake; /// Convenience overload that creates a bare, unscripted fake. public FakeLdapConnectionFactory() : this(new FakeLdapConnection()) { } public FakeLdapConnection Fake { get; } public ILdapConnection Create() => Fake; } // --------------------------------------------------------------------------- // Smoke test: verifies the fake compiles and scripted searches work correctly. // --------------------------------------------------------------------------- public class FakeLdapConnectionSmokeTests { [Fact] public void ScriptedSearch_ReturnsEntry() { var fake = new FakeLdapConnection(); fake.WithUserEntry( dn: "cn=alice,dc=example,dc=com", memberOf: new[] { "cn=admins,dc=example,dc=com" }, displayName: "Alice Smith"); fake.Connect("ldap.example.com", 636, LdapTransport.Ldaps, false, 5000); var results = fake.Search("dc=example,dc=com", "(cn=alice)", new[] { "memberOf", "displayName" }); Assert.Single(results); Assert.Equal("cn=alice,dc=example,dc=com", results[0].Dn); Assert.Equal("Alice Smith", results[0].Attributes["displayName"][0]); Assert.Equal("cn=admins,dc=example,dc=com", results[0].Attributes["memberOf"][0]); } [Fact] public void Connect_RecordsArgs() { var fake = new FakeLdapConnection(); fake.Connect("ldap.example.com", 389, LdapTransport.StartTls, false, 10_000); Assert.NotNull(fake.ConnectArgs); Assert.Equal("ldap.example.com", fake.ConnectArgs!.Value.Host); Assert.Equal(LdapTransport.StartTls, fake.ConnectArgs.Value.Transport); } [Fact] public void ThrowOnUserBind_ThrowsOnSecondBindOnly() { var fake = new FakeLdapConnection().ThrowOnUserBind(); fake.Connect("ldap.example.com", 389, LdapTransport.None, true, 0); // First bind = service account: succeeds. fake.Bind("cn=svc,dc=example,dc=com", "secret"); // Second bind = user: throws (bad user credentials). Assert.Throws( () => fake.Bind("cn=bob,dc=example,dc=com", "wrong")); } [Fact] public void ThrowOnServiceBind_ThrowsOnFirstBind() { var fake = new FakeLdapConnection().ThrowOnServiceBind(); fake.Connect("ldap.example.com", 389, LdapTransport.None, true, 0); Assert.Throws( () => fake.Bind("cn=svc,dc=example,dc=com", "secret")); } [Fact] public void ThrowOnConnect_ThrowsLdapException() { var fake = new FakeLdapConnection().ThrowOnConnect(); Assert.Throws( () => fake.Connect("ldap.example.com", 389, LdapTransport.None, true, 0)); } [Fact] public void Bind_RecordsDn_WhenNotThrowing() { var fake = new FakeLdapConnection(); fake.Connect("ldap.example.com", 636, LdapTransport.Ldaps, false, 5000); fake.Bind("cn=svc,dc=example,dc=com", "secret"); Assert.Contains("cn=svc,dc=example,dc=com", fake.BoundDns); } }