Initial commit: scadaproj umbrella — sister-project index, auth component normalization (design + GAPS), and the built ZB.MOM.WW.Auth shared library (0.1.0, flattened in).
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
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;
|
||||
|
||||
// ---- observation -----
|
||||
|
||||
public (string Host, int Port, LdapTransport Transport, bool AllowInsecure, int TimeoutMs)? ConnectArgs { 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; }
|
||||
|
||||
// ---- 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> and optional <c>displayName</c>.
|
||||
/// </summary>
|
||||
public FakeLdapConnection WithUserEntry(string dn, string[] memberOf, string? displayName = null)
|
||||
{
|
||||
var attrs = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["memberOf"] = memberOf.ToList()
|
||||
};
|
||||
if (displayName is not null)
|
||||
attrs["displayName"] = new[] { displayName };
|
||||
|
||||
_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"/> 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)
|
||||
{
|
||||
ConnectArgs = (host, port, transport, allowInsecure, timeoutMs);
|
||||
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<LdapSearchEntry> Search(
|
||||
string searchBase,
|
||||
string filter,
|
||||
IReadOnlyList<string> attributes)
|
||||
=> _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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user