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.
151 lines
6.0 KiB
C#
151 lines
6.0 KiB
C#
using System.Net.Security;
|
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
|
using ZB.MOM.WW.Auth.Ldap;
|
|
|
|
namespace ZB.MOM.WW.Auth.Ldap.Tests;
|
|
|
|
public class LdapAuthServiceTests
|
|
{
|
|
// Sensible test defaults: insecure plaintext transport (dev/test), a service
|
|
// account set, and DisplayNameAttribute aligned with the fake's "displayName"
|
|
// key so display-name extraction is genuinely exercised.
|
|
private static LdapOptions Opts() => new()
|
|
{
|
|
Enabled = true,
|
|
Server = "x",
|
|
Port = 3893,
|
|
Transport = LdapTransport.None,
|
|
AllowInsecure = true,
|
|
SearchBase = "dc=x",
|
|
ServiceAccountDn = "cn=svc,dc=x",
|
|
ServiceAccountPassword = "svcpw",
|
|
UserNameAttribute = "cn",
|
|
DisplayNameAttribute = "displayName",
|
|
GroupAttribute = "memberOf",
|
|
};
|
|
|
|
[Fact]
|
|
public async Task Succeeds_AndReturnsStrippedGroups_OnValidCredentials()
|
|
{
|
|
var fake = new FakeLdapConnection().WithUserEntry(
|
|
"cn=alice,dc=x",
|
|
memberOf: new[] { "cn=Engineers,ou=g,dc=x", "cn=Viewers,ou=g,dc=x" },
|
|
displayName: "Alice");
|
|
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
|
|
|
|
var r = await svc.AuthenticateAsync(" alice ", "pw", default);
|
|
|
|
Assert.True(r.Succeeded);
|
|
Assert.Equal("alice", r.Username); // trimmed
|
|
Assert.Equal("Alice", r.DisplayName); // from DisplayNameAttribute
|
|
Assert.Equal(new[] { "Engineers", "Viewers" }, r.Groups); // CN= stripped
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BindsServiceAccountThenUser_OnValidCredentials()
|
|
{
|
|
// Non-empty memberOf: fail-closed requires at least one group for success, and this
|
|
// test asserts bind ORDER, so the user must successfully resolve and bind.
|
|
var fake = new FakeLdapConnection().WithUserEntry(
|
|
"cn=alice,dc=x", memberOf: new[] { "cn=Engineers,ou=g,dc=x" }, displayName: "Alice");
|
|
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
|
|
|
|
await svc.AuthenticateAsync("alice", "pw", default);
|
|
|
|
// Service account first, user DN second (bind-then-search-then-bind).
|
|
Assert.Equal(new[] { "cn=svc,dc=x", "cn=alice,dc=x" }, fake.BoundDns);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FallsBackToUsername_WhenNoDisplayName()
|
|
{
|
|
// Non-empty memberOf so fail-closed lets success through; this test only asserts the
|
|
// display-name fallback (no displayName attribute -> username).
|
|
var fake = new FakeLdapConnection().WithUserEntry(
|
|
"cn=bob,dc=x", memberOf: new[] { "cn=Viewers,ou=g,dc=x" });
|
|
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
|
|
|
|
var r = await svc.AuthenticateAsync("bob", "pw", default);
|
|
|
|
Assert.True(r.Succeeded);
|
|
Assert.Equal("bob", r.DisplayName);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Fails_Disabled_WhenNotEnabled()
|
|
{
|
|
var svc = new LdapAuthService(
|
|
Opts() with { Enabled = false },
|
|
new FakeLdapConnectionFactory(new FakeLdapConnection()));
|
|
|
|
Assert.Equal(LdapAuthFailure.Disabled, (await svc.AuthenticateAsync("a", "b", default)).Failure);
|
|
}
|
|
|
|
// --- Auth-006: TLS validation seam — allowInsecure is honoured and a cert-validation
|
|
// callback is threaded into the connection rather than being silently ignored. ---
|
|
|
|
[Fact]
|
|
public async Task Connect_ReceivesAllowInsecureFlag_FromOptions()
|
|
{
|
|
// The allowInsecure flag must reach the connection (it used to be an unused parameter).
|
|
var fake = new FakeLdapConnection().WithUserEntry(
|
|
"cn=alice,dc=x", memberOf: new[] { "cn=Engineers,ou=g,dc=x" });
|
|
var svc = new LdapAuthService(
|
|
Opts() with { AllowInsecure = true }, new FakeLdapConnectionFactory(fake));
|
|
|
|
await svc.AuthenticateAsync("alice", "pw", default);
|
|
|
|
Assert.NotNull(fake.ConnectArgs);
|
|
Assert.True(fake.ConnectArgs!.Value.AllowInsecure);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Connect_ReceivesConfiguredCertValidationCallback()
|
|
{
|
|
// A consumer-supplied RemoteCertificateValidationCallback must be passed through to the
|
|
// connection so production callers can pin a CA / validate the SAN — the seam no longer
|
|
// discards it.
|
|
RemoteCertificateValidationCallback callback = (_, _, _, _) => true;
|
|
var fake = new FakeLdapConnection().WithUserEntry(
|
|
"cn=alice,dc=x", memberOf: new[] { "cn=Engineers,ou=g,dc=x" });
|
|
var svc = new LdapAuthService(
|
|
Opts() with { ServerCertificateValidationCallback = callback },
|
|
new FakeLdapConnectionFactory(fake));
|
|
|
|
await svc.AuthenticateAsync("alice", "pw", default);
|
|
|
|
Assert.Same(callback, fake.ConnectCertCallback);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Connect_NoCertCallbackConfigured_PassesNull()
|
|
{
|
|
// Default: no callback configured -> null reaches the connection, which means the
|
|
// production adapter falls back to OS-trust-store validation (documented behaviour).
|
|
var fake = new FakeLdapConnection().WithUserEntry(
|
|
"cn=alice,dc=x", memberOf: new[] { "cn=Engineers,ou=g,dc=x" });
|
|
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
|
|
|
|
await svc.AuthenticateAsync("alice", "pw", default);
|
|
|
|
Assert.Null(fake.ConnectCertCallback);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreservesEscapedCommaInGroupName_OnRfc4514Dn()
|
|
{
|
|
// C1: a group CN that legitimately contains a comma (escaped per RFC 4514)
|
|
// must be returned intact, not truncated at the escaped comma.
|
|
var fake = new FakeLdapConnection().WithUserEntry(
|
|
"cn=alice,dc=x",
|
|
memberOf: new[] { @"cn=Eng\,ineers,ou=g,dc=x", @"cn=A\2cB,dc=x" },
|
|
displayName: "Alice");
|
|
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
|
|
|
|
var r = await svc.AuthenticateAsync("alice", "pw", default);
|
|
|
|
Assert.True(r.Succeeded);
|
|
Assert.Equal(new[] { "Eng,ineers", "A,B" }, r.Groups);
|
|
}
|
|
}
|