e3d146aa21
- Request UserNameAttribute in the search (de-duplicated, case-insensitive) and return its first value as LdapAuthResult.Username, falling back to the typed name (I2). - The empty-password guard now applies in ServiceAccount mode too: AD treats an empty-password simple bind as an unauthenticated bind and reports success (I3). - Restore the dropped explanatory comments in CompleteWithServiceAccount / SearchUser.
294 lines
11 KiB
C#
294 lines
11 KiB
C#
using System.Net.Security;
|
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
|
using ZB.MOM.WW.Auth.Ldap.Internal;
|
|
|
|
/// <summary>
|
|
/// Test double for <see cref="ILdapConnection"/>. Script results and error
|
|
/// conditions with the builder methods; inspect recorded calls via properties.
|
|
/// Consumed by Task 5 (LdapAuthService) unit tests.
|
|
/// </summary>
|
|
internal sealed class FakeLdapConnection : ILdapConnection
|
|
{
|
|
// ---- scripted state -----
|
|
|
|
private readonly List<LdapSearchEntry> _scriptedEntries = new();
|
|
private readonly HashSet<string> _throwBindDns = new(StringComparer.OrdinalIgnoreCase);
|
|
private bool _throwOnConnect;
|
|
private bool _throwOnServiceBind;
|
|
private bool _throwOnUserBind;
|
|
private int? _firstBindResultCode;
|
|
private bool _throwOnSearch;
|
|
|
|
// ---- observation -----
|
|
|
|
public (string Host, int Port, LdapTransport Transport, bool AllowInsecure, int TimeoutMs)? ConnectArgs { get; private set; }
|
|
|
|
/// <summary>The server-certificate validation callback passed to the most recent <see cref="Connect"/> call.</summary>
|
|
public RemoteCertificateValidationCallback? ConnectCertCallback { get; private set; }
|
|
|
|
public List<string> BoundDns { get; } = new();
|
|
|
|
/// <summary>
|
|
/// Count of <see cref="Bind"/> attempts (including ones that throw). The first attempt is
|
|
/// the service-account bind; the second is the user bind. Used to distinguish the two.
|
|
/// </summary>
|
|
public int BindAttempts { get; private set; }
|
|
|
|
/// <summary>The attribute list passed to the most recent <see cref="Search"/> call (the fake still returns every scripted attribute).</summary>
|
|
public IReadOnlyList<string>? SearchAttributes { get; private set; }
|
|
|
|
/// <summary>Every call in order: <c>connect</c>, <c>bind:<dn></c>, <c>search:<filter></c>.</summary>
|
|
public List<string> Calls { get; } = new();
|
|
|
|
// ---- builder methods -----
|
|
|
|
/// <summary>
|
|
/// Scripts a user entry that will be returned by the next <see cref="Search"/> call.
|
|
/// Builds a minimal attribute bag with <c>memberOf</c>, optional <c>displayName</c> and any
|
|
/// <paramref name="extraAttributes"/> (e.g. <c>sAMAccountName</c>) the test needs.
|
|
/// </summary>
|
|
public FakeLdapConnection WithUserEntry(
|
|
string dn,
|
|
string[] memberOf,
|
|
string? displayName = null,
|
|
IReadOnlyDictionary<string, string[]>? extraAttributes = null)
|
|
{
|
|
var attrs = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["memberOf"] = memberOf.ToList()
|
|
};
|
|
if (displayName is not null)
|
|
attrs["displayName"] = new[] { displayName };
|
|
if (extraAttributes is not null)
|
|
{
|
|
foreach (var (name, values) in extraAttributes)
|
|
attrs[name] = values;
|
|
}
|
|
|
|
_scriptedEntries.Add(new LdapSearchEntry(dn, attrs));
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Configures the fake to throw <see cref="Novell.Directory.Ldap.LdapException"/> when
|
|
/// <see cref="Bind"/> is called for <paramref name="dn"/> (simulates bad credentials).
|
|
/// </summary>
|
|
public FakeLdapConnection ThrowOnBind(string dn)
|
|
{
|
|
_throwBindDns.Add(dn);
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Throw <see cref="Novell.Directory.Ldap.LdapException"/> 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.
|
|
/// </summary>
|
|
public FakeLdapConnection ThrowOnUserBind()
|
|
{
|
|
_throwOnUserBind = true;
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Throw <see cref="Novell.Directory.Ldap.LdapException"/> on the FIRST bind — the
|
|
/// service-account bind — to simulate a service-account misconfiguration. Distinct from
|
|
/// <see cref="ThrowOnUserBind"/>; this fails before the directory search ever runs.
|
|
/// </summary>
|
|
public FakeLdapConnection ThrowOnServiceBind()
|
|
{
|
|
_throwOnServiceBind = true;
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Throw <see cref="Novell.Directory.Ldap.LdapException"/> with <paramref name="resultCode"/> on the
|
|
/// FIRST bind. In user-bind mode the first bind is the user's own, so 49 simulates a wrong
|
|
/// password and e.g. 52 (Unavailable) a domain controller that cannot answer.
|
|
/// </summary>
|
|
public FakeLdapConnection ThrowOnFirstBind(int resultCode)
|
|
{
|
|
_firstBindResultCode = resultCode;
|
|
return this;
|
|
}
|
|
|
|
/// <summary>Throw <see cref="Novell.Directory.Ldap.LdapException"/> from <see cref="Search"/> (unhealthy directory).</summary>
|
|
public FakeLdapConnection ThrowOnSearch()
|
|
{
|
|
_throwOnSearch = true;
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Throw <see cref="Novell.Directory.Ldap.LdapException"/> from <see cref="Connect"/> to
|
|
/// simulate an unreachable directory (infrastructure failure).
|
|
/// </summary>
|
|
public FakeLdapConnection ThrowOnConnect()
|
|
{
|
|
_throwOnConnect = true;
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scripts a search that returns ZERO entries (no <see cref="WithUserEntry"/> call also
|
|
/// yields zero, but this states the intent explicitly). Simulates user-not-found.
|
|
/// </summary>
|
|
public FakeLdapConnection WithNoMatch() => this;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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)
|
|
{
|
|
Calls.Add("connect");
|
|
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++;
|
|
Calls.Add($"bind:{dn}");
|
|
var isServiceBind = BindAttempts == 1;
|
|
|
|
if (isServiceBind && _firstBindResultCode is { } code)
|
|
throw new Novell.Directory.Ldap.LdapException("Bind refused", code, dn);
|
|
|
|
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<LdapSearchEntry> Search(
|
|
string searchBase,
|
|
string filter,
|
|
IReadOnlyList<string> attributes)
|
|
{
|
|
Calls.Add($"search:{filter}");
|
|
SearchAttributes = attributes;
|
|
if (_throwOnSearch)
|
|
throw new Novell.Directory.Ldap.LdapException(
|
|
"Search failed", Novell.Directory.Ldap.LdapException.Unavailable, searchBase);
|
|
return _scriptedEntries.AsReadOnly();
|
|
}
|
|
|
|
public void Dispose() { /* nothing to clean up */ }
|
|
}
|
|
|
|
/// <summary>Factory that always returns the same pre-configured fake instance.</summary>
|
|
internal sealed class FakeLdapConnectionFactory : ILdapConnectionFactory
|
|
{
|
|
/// <summary>Wraps a caller-supplied fake so a test can script it before handing it to the service.</summary>
|
|
public FakeLdapConnectionFactory(FakeLdapConnection fake) => Fake = fake;
|
|
|
|
/// <summary>Convenience overload that creates a bare, unscripted fake.</summary>
|
|
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<Novell.Directory.Ldap.LdapException>(
|
|
() => 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<Novell.Directory.Ldap.LdapException>(
|
|
() => fake.Bind("cn=svc,dc=example,dc=com", "secret"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ThrowOnConnect_ThrowsLdapException()
|
|
{
|
|
var fake = new FakeLdapConnection().ThrowOnConnect();
|
|
|
|
Assert.Throws<Novell.Directory.Ldap.LdapException>(
|
|
() => 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);
|
|
}
|
|
}
|