Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
155 lines
7.1 KiB
C#
155 lines
7.1 KiB
C#
using System.Net.Sockets;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
|
|
|
/// <summary>
|
|
/// Live-service tests against the dev GLAuth instance at <c>localhost:3893</c>. Skipped
|
|
/// when the port is unreachable so the test suite stays portable on boxes without a
|
|
/// running directory. Closes LMX follow-up #4 — the server-side <see cref="LdapUserAuthenticator"/>
|
|
/// is exercised end-to-end against a real LDAP server (same one the Admin process uses),
|
|
/// not just the flow-shape unit tests from PR 19.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The <c>Admin.Tests</c> project already has a live-bind test for its own
|
|
/// <c>LdapAuthService</c>; this pair catches divergence between the two bind paths — the
|
|
/// Server authenticator has to work even when the Server process is on a machine that
|
|
/// doesn't have the Admin assemblies loaded, and the two share no code by design
|
|
/// (cross-app dependency avoidance). If one side drifts past the other on LDAP filter
|
|
/// construction, DN resolution, or memberOf parsing, these tests surface it.
|
|
/// </remarks>
|
|
[Trait("Category", "LiveLdap")]
|
|
public sealed class LdapUserAuthenticatorLiveTests
|
|
{
|
|
private const string GlauthHost = "localhost";
|
|
private const int GlauthPort = 3893;
|
|
|
|
private static bool GlauthReachable()
|
|
{
|
|
try
|
|
{
|
|
using var client = new TcpClient();
|
|
var task = client.ConnectAsync(GlauthHost, GlauthPort);
|
|
return task.Wait(TimeSpan.FromSeconds(1)) && client.Connected;
|
|
}
|
|
catch { return false; }
|
|
}
|
|
|
|
// GLAuth dev directory groups are named identically to the OPC UA roles
|
|
// (ReadOnly / WriteOperate / WriteTune / WriteConfigure / AlarmAck), so the map is an
|
|
// identity translation. The authenticator still exercises every step of the pipeline —
|
|
// bind, memberOf lookup, group-name extraction, GroupToRole lookup — against real LDAP
|
|
// data; the identity map just means the assertion is phrased with no surprise rename
|
|
// in the middle.
|
|
private static LdapOptions GlauthOptions() => new()
|
|
{
|
|
Enabled = true,
|
|
Server = GlauthHost,
|
|
Port = GlauthPort,
|
|
UseTls = false,
|
|
AllowInsecureLdap = true,
|
|
SearchBase = "dc=lmxopcua,dc=local",
|
|
// Search-then-bind: service account resolves the user's full DN (cn=<user> lives
|
|
// under ou=<primary-group>,ou=users), the authenticator binds that DN with the
|
|
// user's password, then stays on the service-account session for memberOf lookup.
|
|
// Without this path, GLAuth ACLs block the authenticated user from reading their
|
|
// own entry in full — a plain self-search returns zero results and the role list
|
|
// ends up empty.
|
|
ServiceAccountDn = "cn=serviceaccount,dc=lmxopcua,dc=local",
|
|
ServiceAccountPassword = "serviceaccount123",
|
|
DisplayNameAttribute = "cn",
|
|
GroupAttribute = "memberOf",
|
|
UserNameAttribute = "cn", // GLAuth keys users by cn — see LdapOptions xml-doc.
|
|
GroupToRole = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["ReadOnly"] = "ReadOnly",
|
|
["WriteOperate"] = WriteAuthzPolicy.RoleWriteOperate,
|
|
["WriteTune"] = WriteAuthzPolicy.RoleWriteTune,
|
|
["WriteConfigure"] = WriteAuthzPolicy.RoleWriteConfigure,
|
|
["AlarmAck"] = "AlarmAck",
|
|
},
|
|
};
|
|
|
|
private static LdapUserAuthenticator NewAuthenticator() =>
|
|
new(GlauthOptions(), NullLogger<LdapUserAuthenticator>.Instance);
|
|
|
|
[Fact]
|
|
public async Task Valid_credentials_bind_and_return_success()
|
|
{
|
|
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
|
|
|
var result = await NewAuthenticator().AuthenticateAsync("readonly", "readonly123", TestContext.Current.CancellationToken);
|
|
|
|
result.Success.ShouldBeTrue(result.Error);
|
|
result.DisplayName.ShouldNotBeNullOrEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Writeop_user_gets_WriteOperate_role_from_group_mapping()
|
|
{
|
|
// Drives end-to-end: bind as writeop, memberOf lists the WriteOperate group, the
|
|
// authenticator surfaces WriteOperate via GroupToRole. If this test fails,
|
|
// WriteAuthzPolicy.IsAllowed for an Operate-tier write would also fail
|
|
// (WriteOperate is the exact string the policy checks for), so the failure mode is
|
|
// concrete, not abstract.
|
|
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
|
|
|
var result = await NewAuthenticator().AuthenticateAsync("writeop", "writeop123", TestContext.Current.CancellationToken);
|
|
|
|
result.Success.ShouldBeTrue(result.Error);
|
|
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteOperate);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Admin_user_gets_multiple_roles_from_multiple_groups()
|
|
{
|
|
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
|
|
|
// 'admin' has primarygroup=ReadOnly and othergroups=[WriteOperate, AlarmAck,
|
|
// WriteTune, WriteConfigure] per the GLAuth dev config — the authenticator must
|
|
// surface every mapped role, not just the primary group. Guards against a regression
|
|
// where the memberOf parsing stops after the first match or misses the primary-group
|
|
// fallback.
|
|
var result = await NewAuthenticator().AuthenticateAsync("admin", "admin123", TestContext.Current.CancellationToken);
|
|
|
|
result.Success.ShouldBeTrue(result.Error);
|
|
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteOperate);
|
|
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteTune);
|
|
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteConfigure);
|
|
result.Roles.ShouldContain("AlarmAck");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Wrong_password_returns_failure()
|
|
{
|
|
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
|
|
|
var result = await NewAuthenticator().AuthenticateAsync("readonly", "wrong-pw", TestContext.Current.CancellationToken);
|
|
|
|
result.Success.ShouldBeFalse();
|
|
result.Error.ShouldNotBeNullOrEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unknown_user_returns_failure()
|
|
{
|
|
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
|
|
|
var result = await NewAuthenticator().AuthenticateAsync("no-such-user-42", "whatever", TestContext.Current.CancellationToken);
|
|
|
|
result.Success.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Empty_credentials_fail_without_touching_the_directory()
|
|
{
|
|
// Pre-flight guard — doesn't require GLAuth.
|
|
var result = await NewAuthenticator().AuthenticateAsync("", "", TestContext.Current.CancellationToken);
|
|
result.Success.ShouldBeFalse();
|
|
result.Error.ShouldContain("Credentials", Case.Insensitive);
|
|
}
|
|
}
|