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);
}
}