Files
scadaproj/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs
T
Joseph Doherty 544a6ddb77 Fix all baseline code-review findings across the six shared libraries
Resolves the 35 findings from the 2026-06-01 baseline (commit 26ba1c7),
test-first for every behavioral change. +51 tests (331 -> 382 passing, 0 failed).

- Telemetry-001 (HIGH): RedactionEnricher now honours property removal, so a
  redactor that drops a key actually scrubs the secret from the event.
- Auth: LDAP validator ValidateOnStart; API-key verify no longer fails on a
  best-effort MarkUsed write or a corrupt scopes column (fail-closed); LDAP cert
  validation hook; KeyPrefix persistence aligned; README algorithm corrected.
- Health: Akka checks return Degraded (not throw) when the cluster isn't up yet;
  GrpcDependencyHealthCheck catch-all; null 'description' rendered; composite
  endpoint builder; XML docs shipped.
- Audit: CompositeAuditWriter no longer re-throws OperationCanceledException;
  TruncatingAuditRedactor over-redact scrubs Target + safe negative max; options
  record; XML docs shipped.
- Configuration: TryAddEnumerable idempotent registration; consistent port
  quoting; strict invariant port parsing; XML docs + README packaged.
- Theme: mobile toggle is now CSS-only (no Bootstrap JS); token/CSS hygiene;
  XML docs on the public parameter surface.

Shared-contract/spec docs updated where the code was the source of truth
(observability service.instance.id, MapZbMetrics, redactor reach). All changes
additive/back-compatible at v0.1.0. code-reviews bookkeeping follows separately.
2026-06-01 11:22:14 -04:00

246 lines
9.0 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;
// ---- 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; }
// ---- 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,
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<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);
}
}