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:
dohertj2
2026-06-01 03:59:23 -04:00
commit 37e23cf9f2
73 changed files with 6836 additions and 0 deletions
@@ -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);
}
}
@@ -0,0 +1,102 @@
// GLAuth integration test — opt-in only.
//
// Prerequisites
// -------------
// 1. A running GLAuth instance (plaintext LDAP, no TLS).
// A ready-made Docker Compose stack lives in the sibling repo:
// ~/Desktop/ScadaBridge/infra/glauth
// Start it with: docker compose up -d
// Default listen address: localhost:3893
//
// 2. Set the following environment variables before running:
// ZB_LDAP_IT=1 (required — gates the test)
// ZB_LDAP_SERVER=localhost (optional, default localhost)
// ZB_LDAP_PORT=3893 (optional, default 3893)
// ZB_LDAP_BASE=dc=lmxopcua,dc=local (optional)
// ZB_LDAP_SVC_DN=cn=svc,dc=lmxopcua,dc=local (service-account DN)
// ZB_LDAP_SVC_PW=svcpass (service-account password)
// ZB_LDAP_USER=alice (test user login)
// ZB_LDAP_PW=alicepass (test user password)
// ZB_LDAP_USERATTR=cn (optional, default cn)
//
// Run command:
// ZB_LDAP_IT=1 ZB_LDAP_SVC_DN=... ZB_LDAP_SVC_PW=... \
// ZB_LDAP_USER=... ZB_LDAP_PW=... \
// dotnet test tests/ZB.MOM.WW.Auth.Ldap.Tests \
// --filter "FullyQualifiedName~GLAuthIntegrationTests"
//
// Without ZB_LDAP_IT=1 the test is SKIPPED — it does not affect the normal CI run.
using System.Net.Sockets;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Ldap;
namespace ZB.MOM.WW.Auth.Ldap.Tests.Integration;
public sealed class GLAuthIntegrationTests
{
/// <summary>
/// Performs a real bind-then-search-then-bind against a live GLAuth instance.
/// Verifies that authentication succeeds and that at least one LDAP group is returned.
/// Skipped unless <c>ZB_LDAP_IT=1</c> is set; skipped again if the server is unreachable.
/// </summary>
[SkippableFact]
public async Task Authenticate_AgainstRealGLAuth_Succeeds()
{
// ------------------------------------------------------------------ opt-in gate
Skip.IfNot(
Environment.GetEnvironmentVariable("ZB_LDAP_IT") == "1",
"Set ZB_LDAP_IT=1 and a reachable GLAuth to run.");
// ------------------------------------------------------------------ read config
var server = Environment.GetEnvironmentVariable("ZB_LDAP_SERVER") ?? "localhost";
var port = int.TryParse(Environment.GetEnvironmentVariable("ZB_LDAP_PORT"), out var p) ? p : 3893;
var baseDn = Environment.GetEnvironmentVariable("ZB_LDAP_BASE") ?? "dc=lmxopcua,dc=local";
var svcDn = Environment.GetEnvironmentVariable("ZB_LDAP_SVC_DN") ?? "";
var svcPw = Environment.GetEnvironmentVariable("ZB_LDAP_SVC_PW") ?? "";
var user = Environment.GetEnvironmentVariable("ZB_LDAP_USER") ?? "";
var pw = Environment.GetEnvironmentVariable("ZB_LDAP_PW") ?? "";
var userAttr = Environment.GetEnvironmentVariable("ZB_LDAP_USERATTR") ?? "cn";
// ------------------------------------------------------------------ reachability probe
try
{
using var tcp = new TcpClient();
// 3-second connect timeout to keep the test suite snappy when the server is absent
var connectTask = tcp.ConnectAsync(server, port);
if (!connectTask.Wait(TimeSpan.FromSeconds(3)))
Skip.If(true, $"GLAuth not reachable at {server}:{port} (connect timed out).");
}
catch (Exception ex)
{
Skip.If(true, $"GLAuth not reachable at {server}:{port}: {ex.Message}");
}
// ------------------------------------------------------------------ build options
var options = new LdapOptions
{
Enabled = true,
Server = server,
Port = port,
Transport = LdapTransport.None,
AllowInsecure = true,
SearchBase = baseDn,
ServiceAccountDn = svcDn,
ServiceAccountPassword = svcPw,
UserNameAttribute = userAttr,
// GLAuth returns memberOf by default; keep the library default
GroupAttribute = "memberOf",
};
// ------------------------------------------------------------------ exercise the real service
// Uses the public single-argument constructor, which wires up NovellLdapConnectionFactory
// internally — no test seam involved.
var svc = new LdapAuthService(options);
var result = await svc.AuthenticateAsync(user, pw, default);
// ------------------------------------------------------------------ assertions
Assert.True(result.Succeeded,
$"Authentication failed: {result.Failure} (server={server}:{port}, user={user})");
Assert.NotEmpty(result.Groups);
}
}
@@ -0,0 +1,156 @@
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Ldap;
namespace ZB.MOM.WW.Auth.Ldap.Tests;
/// <summary>
/// Task 6 failure-mode tests. These pin the fail-closed contract: every error path returns a
/// structured <see cref="LdapAuthResult.Fail(LdapAuthFailure)"/>, the method never throws, and
/// a successful result always carries at least one group.
/// </summary>
public class LdapAuthServiceFailureTests
{
// Mirrors the happy-path test defaults (insecure plaintext dev transport, service account
// set, DisplayNameAttribute aligned with the fake's "displayName" key).
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 BadCredentials_WhenUserBindThrows()
{
var fake = new FakeLdapConnection()
.WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" })
.ThrowOnUserBind();
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
.AuthenticateAsync("alice", "bad", default);
Assert.False(r.Succeeded);
Assert.Equal(LdapAuthFailure.BadCredentials, r.Failure);
}
[Fact]
public async Task UserNotFound_WhenZeroMatches()
{
var fake = new FakeLdapConnection().WithNoMatch();
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
.AuthenticateAsync("ghost", "pw", default);
Assert.False(r.Succeeded);
Assert.Equal(LdapAuthFailure.UserNotFound, r.Failure);
}
[Fact]
public async Task AmbiguousUser_WhenMultipleMatches()
{
var fake = new FakeLdapConnection().WithDuplicateMatch();
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
.AuthenticateAsync("alice", "pw", default);
Assert.False(r.Succeeded);
Assert.Equal(LdapAuthFailure.AmbiguousUser, r.Failure);
}
[Fact]
public async Task AmbiguousUser_DoesNotAttemptUserBind()
{
var fake = new FakeLdapConnection().WithDuplicateMatch();
await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
.AuthenticateAsync("alice", "pw", default);
// Only the service-account bind should have happened; never bind an ambiguous DN.
Assert.Equal(new[] { "cn=svc,dc=x" }, fake.BoundDns);
}
[Fact]
public async Task GroupLookupFailed_WhenUserHasNoGroups()
{
var fake = new FakeLdapConnection()
.WithUserEntry("cn=alice,dc=x", memberOf: Array.Empty<string>());
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
.AuthenticateAsync("alice", "pw", default);
Assert.False(r.Succeeded);
Assert.Equal(LdapAuthFailure.GroupLookupFailed, r.Failure);
}
[Fact]
public async Task ServiceAccountBindFailed_Distinctly_WhenServiceBindThrows()
{
var fake = new FakeLdapConnection()
.WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" })
.ThrowOnServiceBind();
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
.AuthenticateAsync("alice", "pw", default);
Assert.False(r.Succeeded);
Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, r.Failure);
// Distinct from BadCredentials: a service-account problem is a system misconfiguration,
// not the end user's fault.
Assert.NotEqual(LdapAuthFailure.BadCredentials, r.Failure);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task BadCredentials_WhenUsernameNullOrWhitespace_NoConnectionAttempted(string? username)
{
// I4: an empty/whitespace/null username is rejected up front as BadCredentials,
// before any connection or bind is attempted (and a null can't NRE into the catch-all).
var fake = new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" });
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
.AuthenticateAsync(username!, "pw", default);
Assert.False(r.Succeeded);
Assert.Equal(LdapAuthFailure.BadCredentials, r.Failure);
Assert.Null(fake.ConnectArgs); // never connected
Assert.Empty(fake.BoundDns); // never bound
}
[Fact]
public async Task Throws_WhenCancellationRequested()
{
// I3: a pre-cancelled token is observed at entry, before any work.
var fake = new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" });
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => svc.AuthenticateAsync("alice", "pw", new CancellationToken(canceled: true)));
Assert.Null(fake.ConnectArgs); // never connected
}
[Fact]
public async Task NeverThrows_OnConnectFailure()
{
var fake = new FakeLdapConnection()
.WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" })
.ThrowOnConnect();
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
.AuthenticateAsync("alice", "pw", default);
Assert.False(r.Succeeded);
// Directory unreachable is a system-side failure -> bucketed under ServiceAccountBindFailed.
Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, r.Failure);
}
}
@@ -0,0 +1,99 @@
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);
}
[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);
}
}
@@ -0,0 +1,41 @@
using ZB.MOM.WW.Auth.Ldap.Internal;
public class LdapEscapingTests {
[Theory]
[InlineData("a*b", @"a\2ab")]
[InlineData("a(b)", @"a\28b\29")]
[InlineData(@"a\b", @"a\5cb")]
public void Filter_EscapesMetacharacters(string raw, string expected)
=> Assert.Equal(expected, LdapEscaping.Filter(raw));
[Fact]
public void Filter_EscapesNul()
=> Assert.Equal(@"a\00b", LdapEscaping.Filter("a\0b"));
[Fact]
public void Dn_EscapesSpecialChars()
=> Assert.Equal(@"\#cn\,test", LdapEscaping.Dn("#cn,test"));
// M2: each RFC 4514 special char is backslash-escaped, plus leading/trailing space.
[Theory]
[InlineData("a,b", @"a\,b")]
[InlineData("a+b", @"a\+b")]
[InlineData("a\"b", "a\\\"b")]
[InlineData(@"a\b", @"a\\b")]
[InlineData("a<b", @"a\<b")]
[InlineData("a>b", @"a\>b")]
[InlineData("a;b", @"a\;b")]
[InlineData(" ab", @"\ ab")]
[InlineData("ab ", @"ab\ ")]
public void Dn_EscapesEachSpecialChar(string raw, string expected)
=> Assert.Equal(expected, LdapEscaping.Dn(raw));
// C1: RFC 4514 escape-aware first-RDN-value extraction.
[Theory]
[InlineData("cn=Engineers,ou=g,dc=x", "Engineers")] // simple case still works
[InlineData(@"cn=Eng\,ineers,ou=g,dc=x", "Eng,ineers")] // single-char escaped comma
[InlineData(@"cn=A\2cB,dc=x", "A,B")] // hex-escaped comma \2c
[InlineData(@"cn=A\5cB,dc=x", @"A\B")] // hex-escaped backslash \5c
public void FirstRdnValue_IsEscapeAware(string dn, string expected)
=> Assert.Equal(expected, LdapEscaping.FirstRdnValue(dn));
}
@@ -0,0 +1,75 @@
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Ldap;
namespace ZB.MOM.WW.Auth.Ldap.Tests;
public class LdapOptionsValidatorTests
{
private static LdapOptions Opts() => new()
{
Enabled = true,
Server = "x",
Transport = LdapTransport.None,
AllowInsecure = true,
SearchBase = "dc=x",
ServiceAccountDn = "cn=svc,dc=x",
};
[Fact]
public void Validator_Fails_PlainTransport_WhenNotAllowInsecure() =>
Assert.True(new LdapOptionsValidator()
.Validate(null, Opts() with { Transport = LdapTransport.None, AllowInsecure = false })
.Failed);
[Fact]
public void Validator_Fails_WhenServerEmpty() =>
Assert.True(new LdapOptionsValidator()
.Validate(null, Opts() with { Server = " " })
.Failed);
[Fact]
public void Validator_Fails_WhenSearchBaseEmpty() =>
Assert.True(new LdapOptionsValidator()
.Validate(null, Opts() with { SearchBase = "" })
.Failed);
[Fact]
public void Validator_FailureMessage_NamesOffendingField()
{
var result = new LdapOptionsValidator()
.Validate(null, Opts() with { Server = "" });
Assert.True(result.Failed);
Assert.Contains(nameof(LdapOptions.Server), result.FailureMessage);
}
[Fact]
public void Validator_Fails_WhenServiceAccountDnEmpty()
{
// I5: an empty ServiceAccountDn risks an anonymous bind, so it must be rejected
// and the failure message must name the offending key.
var result = new LdapOptionsValidator()
.Validate(null, Opts() with { ServiceAccountDn = " " });
Assert.True(result.Failed);
Assert.Contains(nameof(LdapOptions.ServiceAccountDn), result.FailureMessage);
}
[Fact]
public void Validator_Succeeds_OnValidSecureConfig() =>
Assert.False(new LdapOptionsValidator()
.Validate(null, Opts() with
{
Transport = LdapTransport.Ldaps,
AllowInsecure = false,
Server = "s",
SearchBase = "dc=x",
})
.Failed);
[Fact]
public void Validator_Succeeds_OnInsecureWhenAllowed() =>
Assert.False(new LdapOptionsValidator()
.Validate(null, Opts())
.Failed);
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="Xunit.SkippableFact" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.Auth.Ldap\ZB.MOM.WW.Auth.Ldap.csproj" />
<ProjectReference Include="..\..\src\ZB.MOM.WW.Auth.Abstractions\ZB.MOM.WW.Auth.Abstractions.csproj" />
</ItemGroup>
</Project>