feat(dcl): OPC UA verify-endpoint probe with untrusted-cert capture (T17)
This commit is contained in:
+180
@@ -0,0 +1,180 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Opc.Ua;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// T17: Unit tests for <see cref="RealOpcUaClient.MapVerifyOutcome"/> — the pure
|
||||
/// (exception + captured cert) → <see cref="VerifyEndpointResult"/> mapping that backs the
|
||||
/// OPC UA verify-endpoint probe. These exercise every <see cref="VerifyFailureKind"/>
|
||||
/// branch WITHOUT a live OPC UA server, so the classification logic is covered
|
||||
/// deterministically.
|
||||
/// </summary>
|
||||
public class VerifyEndpointResultMappingTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a throwaway self-signed certificate so the untrusted-cert branch can be
|
||||
/// exercised with a real <see cref="X509Certificate2"/> (and real DER bytes) without a
|
||||
/// live server. Not persisted; only its public fields are asserted.
|
||||
/// </summary>
|
||||
private static X509Certificate2 BuildSelfSignedCert(
|
||||
string subjectName = "CN=ScadaBridge-Verify-Test")
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var request = new CertificateRequest(
|
||||
subjectName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
var notBefore = DateTimeOffset.UtcNow.AddMinutes(-5);
|
||||
var notAfter = DateTimeOffset.UtcNow.AddDays(1);
|
||||
return request.CreateSelfSigned(notBefore, notAfter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CapturedCert_WithValidationException_MapsToUntrustedCertificate()
|
||||
{
|
||||
using var cert = BuildSelfSignedCert();
|
||||
// A certificate-validation rejection surfaces as a ServiceResultException with
|
||||
// BadCertificateUntrusted; the captured cert is what makes the kind authoritative.
|
||||
var ex = new ServiceResultException(
|
||||
StatusCodes.BadCertificateUntrusted, "Certificate is not trusted.");
|
||||
|
||||
var result = RealOpcUaClient.MapVerifyOutcome(ex, cert);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(VerifyFailureKind.UntrustedCertificate, result.FailureKind);
|
||||
Assert.NotNull(result.Cert);
|
||||
Assert.Equal(cert.Thumbprint, result.Cert!.Thumbprint);
|
||||
Assert.Equal(cert.Subject, result.Cert.Subject);
|
||||
Assert.Equal(cert.Issuer, result.Cert.Issuer);
|
||||
// The captured DER must round-trip to the original raw bytes verbatim.
|
||||
Assert.Equal(Convert.ToBase64String(cert.RawData), result.Cert.DerBase64);
|
||||
Assert.Equal(DateTimeKind.Utc, result.Cert.NotBeforeUtc.Kind);
|
||||
Assert.Equal(DateTimeKind.Utc, result.Cert.NotAfterUtc.Kind);
|
||||
Assert.NotNull(result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CapturedCert_WithoutException_MapsToUntrustedCertificate()
|
||||
{
|
||||
// The validator may capture+reject the cert and the SDK then fail with a different
|
||||
// (or no) exception. A captured cert ALWAYS dominates the classification.
|
||||
using var cert = BuildSelfSignedCert();
|
||||
|
||||
var result = RealOpcUaClient.MapVerifyOutcome(null, cert);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(VerifyFailureKind.UntrustedCertificate, result.FailureKind);
|
||||
Assert.NotNull(result.Cert);
|
||||
Assert.Equal(cert.Thumbprint, result.Cert!.Thumbprint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimeoutException_MapsToTimeout()
|
||||
{
|
||||
var result = RealOpcUaClient.MapVerifyOutcome(new TimeoutException("took too long"), null);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(VerifyFailureKind.Timeout, result.FailureKind);
|
||||
Assert.Null(result.Cert);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OperationCanceledException_MapsToTimeout()
|
||||
{
|
||||
var result = RealOpcUaClient.MapVerifyOutcome(new OperationCanceledException(), null);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(VerifyFailureKind.Timeout, result.FailureKind);
|
||||
Assert.Null(result.Cert);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServiceResultException_BadRequestTimeout_MapsToTimeout()
|
||||
{
|
||||
var ex = new ServiceResultException(StatusCodes.BadRequestTimeout, "request timed out");
|
||||
|
||||
var result = RealOpcUaClient.MapVerifyOutcome(ex, null);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(VerifyFailureKind.Timeout, result.FailureKind);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(StatusCodes.BadUserAccessDenied)]
|
||||
[InlineData(StatusCodes.BadIdentityTokenRejected)]
|
||||
[InlineData(StatusCodes.BadIdentityTokenInvalid)]
|
||||
public void ServiceResultException_AuthCodes_MapToAuthFailed(uint statusCode)
|
||||
{
|
||||
var ex = new ServiceResultException(statusCode, "access denied");
|
||||
|
||||
var result = RealOpcUaClient.MapVerifyOutcome(ex, null);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(VerifyFailureKind.AuthFailed, result.FailureKind);
|
||||
Assert.Null(result.Cert);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(StatusCodes.BadConnectionRejected)]
|
||||
[InlineData(StatusCodes.BadNotConnected)]
|
||||
public void ServiceResultException_ConnectionCodes_MapToUnreachable(uint statusCode)
|
||||
{
|
||||
var ex = new ServiceResultException(statusCode, "no route");
|
||||
|
||||
var result = RealOpcUaClient.MapVerifyOutcome(ex, null);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(VerifyFailureKind.Unreachable, result.FailureKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SocketException_MapsToUnreachable()
|
||||
{
|
||||
var ex = new System.Net.Sockets.SocketException(
|
||||
(int)System.Net.Sockets.SocketError.ConnectionRefused);
|
||||
|
||||
var result = RealOpcUaClient.MapVerifyOutcome(ex, null);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(VerifyFailureKind.Unreachable, result.FailureKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SocketExceptionWrappedInServiceResult_MapsToUnreachable()
|
||||
{
|
||||
// The SDK surfaces a refused/DNS connect as a generic ServiceResultException whose
|
||||
// inner exception is the socket failure; the inner socket cause must dominate.
|
||||
var inner = new System.Net.Sockets.SocketException(
|
||||
(int)System.Net.Sockets.SocketError.HostNotFound);
|
||||
var ex = new ServiceResultException(StatusCodes.BadUnexpectedError, "connect failed", inner);
|
||||
|
||||
var result = RealOpcUaClient.MapVerifyOutcome(ex, null);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(VerifyFailureKind.Unreachable, result.FailureKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenericException_MapsToServerError()
|
||||
{
|
||||
var result = RealOpcUaClient.MapVerifyOutcome(new InvalidOperationException("boom"), null);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(VerifyFailureKind.ServerError, result.FailureKind);
|
||||
Assert.Null(result.Cert);
|
||||
Assert.NotNull(result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullException_NullCert_MapsToSuccess()
|
||||
{
|
||||
var result = RealOpcUaClient.MapVerifyOutcome(null, null);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Null(result.FailureKind);
|
||||
Assert.Null(result.Error);
|
||||
Assert.Null(result.Cert);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user