using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.Auth.Abstractions.Roles; using ZB.MOM.WW.OtOpcUa.Host.OpcUa; using ZB.MOM.WW.OtOpcUa.Security.Ldap; namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; /// /// Verifies translates app /// outcomes into OpcUaUserAuthResult, resolves roles from the directory's groups /// through the shared seam (Task 1.2), unions any pre-resolved /// roles (the DevStub Administrator grant) in, and turns LDAP backend exceptions into a denial rather /// than letting them escape into the SDK. /// public sealed class LdapOpcUaUserAuthenticatorTests { /// On success the data-plane authenticator resolves roles via the mapper from the /// returned Groups — not from the auth result's Roles field — and grants identity. [Fact] public async Task Authenticate_LDAP_success_resolves_roles_via_mapper_from_groups() { // Library-style result: groups present, Roles empty (the real path). The mapper maps the // group "configeditor" -> "Designer" (canonical, Task 1.7). var ldap = new FakeLdap(new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty(), null)); var mapper = new FakeMapper(g => g.Select(x => x == "configeditor" ? "Designer" : x).ToArray()); var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger.Instance); var result = await sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None); result.Success.ShouldBeTrue(); result.DisplayName.ShouldBe("Alice"); result.Roles.ShouldBe(new[] { "Designer" }); } /// The DevStub pre-resolved roles (Administrator) survive the move to the mapper: they are /// unioned with the mapper output so the dev grant still reaches the OPC UA session. [Fact] public async Task Authenticate_devstub_preresolved_roles_are_unioned_with_mapper() { // DevStub-shaped result: group "dev", pre-resolved role "Administrator". Mapper maps "dev" to // nothing, so the union is exactly {Administrator}. var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "Administrator" }, null)); var mapper = new FakeMapper(_ => Array.Empty()); var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger.Instance); var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None); result.Success.ShouldBeTrue(); result.Roles.ShouldBe(new[] { "Administrator" }); } /// A mapper fault (e.g. DB outage) must not deny an authenticated session — it falls /// back to the pre-resolved roles, matching the login endpoint's behaviour. [Fact] public async Task Authenticate_mapper_fault_falls_back_to_preresolved_roles() { var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "Administrator" }, null)); var mapper = new FakeMapper(_ => throw new InvalidOperationException("DB down")); var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger.Instance); var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None); result.Success.ShouldBeTrue(); result.Roles.ShouldBe(new[] { "Administrator" }); } /// Verifies that LDAP authentication failure returns Deny result with error text. [Fact] public async Task Authenticate_LDAP_failure_returns_Deny_with_error_text() { var ldap = new FakeLdap(new LdapAuthResult(false, null, "mallory", Array.Empty(), Array.Empty(), "Invalid username or password")); var mapper = new FakeMapper(g => g.ToArray()); var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger.Instance); var result = await sut.AuthenticateUserNameAsync("mallory", "wrong", CancellationToken.None); result.Success.ShouldBeFalse(); result.Error.ShouldBe("Invalid username or password"); } /// Verifies that LDAP exceptions are converted to backend error denial results. [Fact] public async Task Authenticate_LDAP_exception_returns_backend_error_denial() { var ldap = new FakeLdap(_ => throw new InvalidOperationException("LDAP unreachable")); var mapper = new FakeMapper(g => g.ToArray()); var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger.Instance); var result = await sut.AuthenticateUserNameAsync("anyone", "x", CancellationToken.None); result.Success.ShouldBeFalse(); result.Error.ShouldNotBeNull(); result.Error.ShouldContain("backend"); } /// Verifies that authentication falls back to username when LDAP omits display name. [Fact] public async Task Authenticate_falls_back_to_username_when_LDAP_omits_display_name() { var ldap = new FakeLdap(new LdapAuthResult(true, null, "alice", new[] { "ReadOnly" }, Array.Empty(), null)); var mapper = new FakeMapper(g => g.ToArray()); var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger.Instance); var result = await sut.AuthenticateUserNameAsync("alice", "x", CancellationToken.None); result.Success.ShouldBeTrue(); result.DisplayName.ShouldBe("alice"); } /// Builds an IServiceScopeFactory whose scopes resolve the supplied mapper. private static IServiceScopeFactory ScopeFactoryWith(IGroupRoleMapper mapper) { var services = new ServiceCollection(); services.AddScoped(_ => mapper); return services.BuildServiceProvider().GetRequiredService(); } /// Test fake implementation of LDAP authentication service. private sealed class FakeLdap : ILdapAuthService { private readonly Func _handler; /// Initializes the fake with a fixed result that ignores the username. /// The result to return for any authentication attempt. public FakeLdap(LdapAuthResult fixed_) => _handler = _ => fixed_; /// Initializes the fake with a handler function for custom results. /// The handler to invoke with the username to produce a result. public FakeLdap(Func handler) => _handler = handler; /// Authenticates a user asynchronously via the handler function. /// The username to authenticate. /// The password (ignored by the fake). /// Cancellation token for the operation. public Task AuthenticateAsync(string username, string password, CancellationToken ct = default) => Task.FromResult(_handler(username)); } /// Test fake group→role mapper driven by a delegate over the supplied groups. private sealed class FakeMapper(Func, IReadOnlyList> map) : IGroupRoleMapper { /// Maps groups to roles via the configured delegate; Scope is always null. /// The LDAP groups to map. /// The cancellation token. public Task> MapAsync(IReadOnlyList groups, CancellationToken ct) => Task.FromResult(new GroupRoleMapping(map(groups), Scope: null)); } }