fae960c157
v2-ci / build (push) Failing after 47s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Opc.Ua.Server was pinned 1.5.374.126 while Client/Configuration were 1.5.378.106, so the published Host unified Opc.Ua.Core to 1.5.378 (which dropped Opc.Ua.INodeIdFactory that Server 1.5.374 referenced). Every driver-role node (and the fused site nodes) failed to start the OPC UA server with TypeLoadException, leaving the OPC data plane dead and the site UIs at 503. Align all OPC UA packages to 1.5.378.106 (bump Server; drop the Opc.Ua.Configuration/Client VersionOverrides in OpcUaServer + its integration tests) and port the server host to the 1.5.378 async API: - ApplicationInstance requires an ITelemetryContext ctor (DefaultTelemetry.Create) - Start/Stop/LoadApplicationConfiguration/Validate -> async; CheckApplicationInstanceCertificate -> CheckApplicationInstanceCertificatesAsync - ImpersonateEventHandler is now (ISession, ImpersonateEventArgs) - UserNameIdentityToken.DecryptedPassword is now byte[] (UTF-8 decode) - tests ported (byte[] passwords; async discovery/session/read client API) Verified: full solution builds, OpcUaServer unit tests 52/52, and in docker-dev all six OPC endpoints (4840-4845) listen and the site UIs return 302 (were 503). End-to-end OPC behaviour (read/write/subscribe/security under 1.5.378) still needs a functional client test.
139 lines
6.9 KiB
C#
139 lines
6.9 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// F13c — verifies the impersonation handler routes UserName tokens through
|
|
/// <see cref="IOpcUaUserAuthenticator"/> and translates its result into the SDK's
|
|
/// <see cref="ImpersonateEventArgs"/> shape (granted identity vs. rejection status).
|
|
/// </summary>
|
|
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" };
|
|
|
|
/// <summary>Verifies successful UserName token impersonation sets identity and clears validation error.</summary>
|
|
[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<object>.Instance);
|
|
|
|
args.Identity.ShouldNotBeNull();
|
|
args.IdentityValidationError.ShouldBeNull();
|
|
authenticator.LastUsername.ShouldBe("alice");
|
|
authenticator.LastPassword.ShouldBe("secret");
|
|
}
|
|
|
|
/// <summary>Verifies failed UserName token impersonation sets validation error and clears identity.</summary>
|
|
[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<object>.Instance);
|
|
|
|
args.Identity.ShouldBeNull();
|
|
args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected);
|
|
args.IdentityValidationError.LocalizedText.Text.ShouldContain("Invalid credentials");
|
|
}
|
|
|
|
/// <summary>Verifies anonymous identity tokens are passed through to the SDK default handler.</summary>
|
|
[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<string>()));
|
|
|
|
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.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");
|
|
}
|
|
|
|
/// <summary>Verifies authenticator exceptions result in rejection with validation error.</summary>
|
|
[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<object>.Instance);
|
|
|
|
args.Identity.ShouldBeNull();
|
|
args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected);
|
|
}
|
|
|
|
/// <summary>Verifies null username is normalized to empty string before authenticator call.</summary>
|
|
[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<object>.Instance);
|
|
|
|
authenticator.LastUsername.ShouldBe(string.Empty);
|
|
}
|
|
|
|
/// <summary>Verifies NullOpcUaUserAuthenticator always returns denial result.</summary>
|
|
[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
|
|
{
|
|
/// <summary>Gets the username passed to the last authentication call.</summary>
|
|
public string? LastUsername { get; private set; }
|
|
/// <summary>Gets the password passed to the last authentication call.</summary>
|
|
public string? LastPassword { get; private set; }
|
|
|
|
/// <summary>Authenticates a username and password, recording them for inspection by tests.</summary>
|
|
/// <param name="username">The username to authenticate.</param>
|
|
/// <param name="password">The password to authenticate.</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
/// <returns>The predefined authentication result.</returns>
|
|
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
|
|
{
|
|
LastUsername = username;
|
|
LastPassword = password;
|
|
return Task.FromResult(outcome);
|
|
}
|
|
}
|
|
|
|
/// <summary>Test authenticator that throws an exception during authentication.</summary>
|
|
private sealed class ThrowingAuthenticator(Exception ex) : IOpcUaUserAuthenticator
|
|
{
|
|
/// <summary>Authenticates by throwing the configured exception.</summary>
|
|
/// <param name="username">The username to authenticate.</param>
|
|
/// <param name="password">The password to authenticate.</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
/// <returns>Never returns; always throws the configured exception.</returns>
|
|
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
|
|
=> Task.FromException<OpcUaUserAuthResult>(ex);
|
|
}
|
|
}
|