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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user