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);
}
}