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; /// /// Live-service tests against the dev GLAuth instance at localhost:3893. 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 /// 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. /// /// /// The Admin.Tests project already has a live-bind test for its own /// LdapAuthService; 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. /// [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= lives // under ou=,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.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); } }