using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Security.Ldap; using LdapTransport = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport; using LdapAuthFailure = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapAuthFailure; using LibILdapAuthService = ZB.MOM.WW.Auth.Abstractions.Ldap.ILdapAuthService; using LibLdapAuthResult = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapAuthResult; namespace ZB.MOM.WW.OtOpcUa.Security.Tests; /// /// Task 1.2 — proves (the app's ILdapAuthService wrapper over /// the shared ZB.MOM.WW.Auth.Ldap service) preserves the two app-only concerns the library /// does not model: the Enabled master switch and the DevStubMode bypass. Both must /// short-circuit WITHOUT delegating to the library. On the real path it adapts the library result /// (groups, never roles) onto the app result shape with roles left for the downstream mapper. /// public sealed class OtOpcUaLdapAuthServiceTests { private static OtOpcUaLdapAuthService Build(LdapOptions options, RecordingLibService inner) => new(options, inner, NullLogger.Instance); /// DevStubMode on → stub Administrator success WITHOUT hitting the library. [Fact] public async Task DevStubMode_grants_Administrator_without_calling_the_library() { var inner = new RecordingLibService(LibLdapAuthResult.Fail(LdapAuthFailure.BadCredentials)); var sut = Build(new LdapOptions { Enabled = true, DevStubMode = true }, inner); var result = await sut.AuthenticateAsync("anyone", "anything", CancellationToken.None); result.Success.ShouldBeTrue(); result.Username.ShouldBe("anyone"); result.Groups.ShouldBe(new[] { "dev" }); result.Roles.ShouldBe(new[] { "Administrator" }); inner.Called.ShouldBeFalse("DevStubMode must never reach the real directory client"); } /// Enabled=false → denial, no library call (master switch wins over DevStubMode). [Fact] public async Task Disabled_denies_without_calling_the_library_even_with_devstub() { var inner = new RecordingLibService(LibLdapAuthResult.Success("x", "x", new[] { "g" })); var sut = Build(new LdapOptions { Enabled = false, DevStubMode = true }, inner); var result = await sut.AuthenticateAsync("user", "pw", CancellationToken.None); result.Success.ShouldBeFalse(); result.Error.ShouldBe("LDAP authentication is disabled."); inner.Called.ShouldBeFalse("a disabled provider must never touch the network"); } /// Real path: a library success surfaces its Groups; Roles are left empty for the /// downstream mapper (the library returns groups, not roles). [Fact] public async Task Real_path_success_surfaces_groups_and_leaves_roles_for_the_mapper() { var inner = new RecordingLibService( LibLdapAuthResult.Success("alice", "Alice User", new[] { "ReadOnly", "Engineers" })); var sut = Build( new LdapOptions { Enabled = true, DevStubMode = false, Transport = LdapTransport.Ldaps }, inner); var result = await sut.AuthenticateAsync("alice", "secret", CancellationToken.None); inner.Called.ShouldBeTrue(); result.Success.ShouldBeTrue(); result.Username.ShouldBe("alice"); result.DisplayName.ShouldBe("Alice User"); result.Groups.ShouldBe(new[] { "ReadOnly", "Engineers" }); result.Roles.ShouldBeEmpty(); } /// Real path: a library failure folds into a fail-closed error string. [Fact] public async Task Real_path_failure_folds_into_error() { var inner = new RecordingLibService(LibLdapAuthResult.Fail(LdapAuthFailure.BadCredentials)); var sut = Build( new LdapOptions { Enabled = true, DevStubMode = false, Transport = LdapTransport.Ldaps }, inner); var result = await sut.AuthenticateAsync("alice", "wrong", CancellationToken.None); inner.Called.ShouldBeTrue(); result.Success.ShouldBeFalse(); result.Error.ShouldBe("Invalid username or password"); } /// Insecure transport without AllowInsecure fails closed at the auth boundary WITHOUT /// reaching the library — preserving the bespoke service's login-time guard after UseTls→Transport. [Fact] public async Task Insecure_transport_without_AllowInsecure_fails_closed_without_calling_library() { var inner = new RecordingLibService(LibLdapAuthResult.Success("x", "x", new[] { "g" })); var sut = Build( new LdapOptions { Enabled = true, DevStubMode = false, Transport = LdapTransport.None, AllowInsecure = false }, inner); var result = await sut.AuthenticateAsync("alice", "secret", CancellationToken.None); result.Success.ShouldBeFalse(); result.Error.ShouldNotBeNull(); result.Error!.ShouldContain("Insecure LDAP is disabled"); inner.Called.ShouldBeFalse(); } /// Empty username/password are rejected up front without a library call. [Theory] [InlineData("", "pw")] [InlineData("user", "")] public async Task Empty_credentials_are_rejected_without_calling_library(string user, string pw) { var inner = new RecordingLibService(LibLdapAuthResult.Success("x", "x", new[] { "g" })); var sut = Build(new LdapOptions { Enabled = true, Transport = LdapTransport.Ldaps }, inner); var result = await sut.AuthenticateAsync(user, pw, CancellationToken.None); result.Success.ShouldBeFalse(); inner.Called.ShouldBeFalse(); } /// Records whether the library service was invoked and returns a canned result. private sealed class RecordingLibService(LibLdapAuthResult result) : LibILdapAuthService { public bool Called { get; private set; } public Task AuthenticateAsync(string username, string password, CancellationToken ct) { Called = true; return Task.FromResult(result); } } }