544a6ddb77
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.
246 lines
9.0 KiB
C#
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);
|
|
}
|
|
}
|