using System.Text; using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Server; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// F13c — verifies the impersonation handler routes UserName tokens through /// and translates its result into the SDK's /// shape (granted identity vs. rejection status). /// public sealed class OpcUaApplicationHostImpersonationTests { private static readonly UserTokenPolicy UserNamePolicy = new(UserTokenType.UserName) { PolicyId = "username_basic256sha256" }; private static readonly UserTokenPolicy AnonPolicy = new(UserTokenType.Anonymous) { PolicyId = "anonymous" }; /// Verifies successful UserName token impersonation sets identity and clears validation error. [Fact] public void HandleImpersonation_username_success_sets_identity_and_no_validation_error() { var token = new UserNameIdentityToken { UserName = "alice", DecryptedPassword = Encoding.UTF8.GetBytes("secret") }; var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription()); var authenticator = new RecordingAuthenticator( OpcUaUserAuthResult.Allow("Alice", new[] { "ReadOnly", "WriteOperate" })); OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger.Instance); args.Identity.ShouldNotBeNull(); args.IdentityValidationError.ShouldBeNull(); authenticator.LastUsername.ShouldBe("alice"); authenticator.LastPassword.ShouldBe("secret"); } /// Verifies failed UserName token impersonation sets validation error and clears identity. [Fact] public void HandleImpersonation_username_denial_sets_validation_error_and_no_identity() { var token = new UserNameIdentityToken { UserName = "mallory", DecryptedPassword = Encoding.UTF8.GetBytes("wrong") }; var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription()); var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Deny("Invalid credentials")); OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger.Instance); args.Identity.ShouldBeNull(); args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected); args.IdentityValidationError.LocalizedText.Text.ShouldContain("Invalid credentials"); } /// Verifies anonymous identity tokens are passed through to the SDK default handler. [Fact] public void HandleImpersonation_anonymous_token_falls_through_to_sdk_default() { var args = new ImpersonateEventArgs(new AnonymousIdentityToken(), AnonPolicy, new EndpointDescription()); var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Allow("x", Array.Empty())); OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger.Instance); // Handler leaves anonymous tokens untouched — no identity, no validation error. args.Identity.ShouldBeNull(); args.IdentityValidationError.ShouldBeNull(); authenticator.LastUsername.ShouldBeNull("anonymous tokens must not hit the authenticator"); } /// Verifies authenticator exceptions result in rejection with validation error. [Fact] public void HandleImpersonation_authenticator_throwing_results_in_rejection() { var token = new UserNameIdentityToken { UserName = "bob", DecryptedPassword = Encoding.UTF8.GetBytes("x") }; var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription()); var authenticator = new ThrowingAuthenticator(new InvalidOperationException("LDAP unreachable")); OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger.Instance); args.Identity.ShouldBeNull(); args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected); } /// Verifies null username is normalized to empty string before authenticator call. [Fact] public void HandleImpersonation_null_username_treated_as_empty_string() { var token = new UserNameIdentityToken { UserName = null, DecryptedPassword = Encoding.UTF8.GetBytes("abc") }; var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription()); var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Deny("no user")); OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger.Instance); authenticator.LastUsername.ShouldBe(string.Empty); } /// Verifies NullOpcUaUserAuthenticator always returns denial result. [Fact] public async Task NullOpcUaUserAuthenticator_always_denies() { var result = await NullOpcUaUserAuthenticator.Instance .AuthenticateUserNameAsync("anyone", "anything", CancellationToken.None); result.Success.ShouldBeFalse(); result.Error.ShouldNotBeNull(); result.Roles.ShouldBeEmpty(); } private sealed class RecordingAuthenticator(OpcUaUserAuthResult outcome) : IOpcUaUserAuthenticator { /// Gets the username passed to the last authentication call. public string? LastUsername { get; private set; } /// Gets the password passed to the last authentication call. public string? LastPassword { get; private set; } /// Authenticates a username and password, recording them for inspection by tests. /// The username to authenticate. /// The password to authenticate. /// The cancellation token. /// The predefined authentication result. public Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct) { LastUsername = username; LastPassword = password; return Task.FromResult(outcome); } } /// Test authenticator that throws an exception during authentication. private sealed class ThrowingAuthenticator(Exception ex) : IOpcUaUserAuthenticator { /// Authenticates by throwing the configured exception. /// The username to authenticate. /// The password to authenticate. /// The cancellation token. /// Never returns; always throws the configured exception. public Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct) => Task.FromException(ex); } }