diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/VerifyEndpointCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/VerifyEndpointCommands.cs
new file mode 100644
index 00000000..946251b3
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/VerifyEndpointCommands.cs
@@ -0,0 +1,80 @@
+namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
+
+///
+/// Command (Central UI → site DCL manager) to verify a data-connection endpoint
+/// configuration without persisting it: connect, capture the server certificate if it
+/// is untrusted, then disconnect. The probe is read-only and never trusts the server
+/// certificate — an untrusted certificate is captured and the connect is rejected
+/// (cert trust is a separate, later action). Only OPC UA is supported today; other
+/// protocols return a with
+/// .
+///
+/// Name of the data connection being verified (for logging/correlation).
+/// Protocol type string (e.g. "OpcUa"); matched case-insensitively.
+/// Serialized endpoint configuration JSON (the typed OPC UA endpoint shape).
+public record VerifyEndpointCommand(string ConnectionName, string Protocol, string ConfigJson);
+
+///
+/// Classification of why an endpoint verification failed. Distinguishes the cases the
+/// Central UI must present differently — most importantly
+/// , which carries a capturable server certificate the
+/// operator can choose to trust in a later step.
+///
+public enum VerifyFailureKind
+{
+ /// The endpoint host could not be reached (DNS failure, connection refused, socket error).
+ Unreachable,
+
+ /// The server rejected the supplied user identity (anonymous/username/certificate).
+ AuthFailed,
+
+ ///
+ /// The server presented a certificate that is not trusted by this site. The
+ /// certificate is captured in so it can be
+ /// reviewed and trusted in a later step; the probe itself rejected it.
+ ///
+ UntrustedCertificate,
+
+ /// The verification did not complete within the allotted time budget.
+ Timeout,
+
+ /// Any other server-side or unexpected failure (including unsupported protocol).
+ ServerError
+}
+
+///
+/// Details of a server certificate captured during a verification probe. Carries the
+/// fields the Central UI needs to display the certificate for an operator trust decision,
+/// plus the raw DER (base64) so the certificate can be persisted to the trusted store
+/// verbatim in a later step.
+///
+/// The certificate SHA-1 thumbprint (hex).
+/// The certificate subject distinguished name.
+/// The certificate issuer distinguished name.
+/// The not-before validity bound (UTC).
+/// The not-after validity bound (UTC).
+/// The raw DER-encoded certificate, base64-encoded.
+public record ServerCertInfo(
+ string Thumbprint,
+ string Subject,
+ string Issuer,
+ DateTime NotBeforeUtc,
+ DateTime NotAfterUtc,
+ string DerBase64);
+
+///
+/// Result of a . On success ,
+/// , and are all null. On failure
+/// classifies the failure and carries a
+/// human-readable message; is populated only when
+/// is .
+///
+/// True if a session was established (the endpoint config is valid and reachable).
+/// The failure classification, or null on success.
+/// A human-readable error message, or null on success.
+/// The captured untrusted server certificate, or null.
+public record VerifyEndpointResult(
+ bool Success,
+ VerifyFailureKind? FailureKind,
+ string? Error,
+ ServerCertInfo? Cert);
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs
index 2dec25b3..29fa5646 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs
@@ -1,8 +1,12 @@
using Akka.Actor;
using Akka.Event;
+using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
+using ZB.MOM.WW.ScadaBridge.Commons.Serialization;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
+using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
@@ -20,6 +24,11 @@ public class DataConnectionManagerActor : ReceiveActor
private readonly DataConnectionOptions _options;
private readonly ISiteHealthCollector _healthCollector;
private readonly ISiteEventLogger? _siteEventLogger;
+ // T17: deployment-wide OPC UA application identity / cert-store paths — the same
+ // global options the DataConnectionFactory feeds to RealOpcUaClient when creating OPC
+ // UA connections. Needed by the verify-endpoint probe (VerifyEndpointCommand), which
+ // builds an ApplicationConfiguration directly rather than through a connection actor.
+ private readonly OpcUaGlobalOptions _opcUaGlobalOptions;
private readonly Dictionary _connectionActors = new();
///
@@ -29,16 +38,23 @@ public class DataConnectionManagerActor : ReceiveActor
/// Configuration options for data connections.
/// Collector for site health metrics reported by connection actors.
/// Optional logger for site event entries; null disables site event logging.
+ ///
+ /// Deployment-wide OPC UA application identity / cert-store paths used by the
+ /// verify-endpoint probe; null falls back to defaults (mirrors
+ /// 's default-options constructor).
+ ///
public DataConnectionManagerActor(
IDataConnectionFactory factory,
DataConnectionOptions options,
ISiteHealthCollector healthCollector,
- ISiteEventLogger? siteEventLogger = null)
+ ISiteEventLogger? siteEventLogger = null,
+ OpcUaGlobalOptions? opcUaGlobalOptions = null)
{
_factory = factory;
_options = options;
_healthCollector = healthCollector;
_siteEventLogger = siteEventLogger;
+ _opcUaGlobalOptions = opcUaGlobalOptions ?? new OpcUaGlobalOptions();
Receive(HandleCreateConnection);
Receive(HandleRoute);
@@ -52,6 +68,7 @@ public class DataConnectionManagerActor : ReceiveActor
Receive(HandleBrowse);
Receive(HandleSearch);
Receive(HandleReadTagValues);
+ Receive(HandleVerifyEndpoint);
}
private void HandleCreateConnection(CreateConnectionCommand command)
@@ -243,6 +260,46 @@ public class DataConnectionManagerActor : ReceiveActor
}
}
+ ///
+ /// T17: Handles a from the Central UI's "Verify"
+ /// action — probes the endpoint config WITHOUT persisting it (connect → capture an
+ /// untrusted cert → disconnect) and pipes a structured
+ /// back to the sender. Verify does NOT require an existing connection (the config may be
+ /// brand-new and unsaved), so — unlike the routed browse/read handlers — it does not look
+ /// up a connection actor; it runs the probe directly. Only OPC UA is supported today.
+ ///
+ private void HandleVerifyEndpoint(VerifyEndpointCommand cmd)
+ {
+ if (!string.Equals(cmd.Protocol, "OpcUa", StringComparison.OrdinalIgnoreCase))
+ {
+ Sender.Tell(new VerifyEndpointResult(
+ false, VerifyFailureKind.ServerError,
+ "Verify is only supported for OPC UA connections.", null));
+ return;
+ }
+
+ OpcUaEndpointConfig config;
+ try
+ {
+ (config, _) = OpcUaEndpointConfigSerializer.Deserialize(cmd.ConfigJson);
+ }
+ catch (Exception ex)
+ {
+ // Defensive: Deserialize is designed not to throw (it classifies Malformed), but
+ // a verify must never crash the manager — surface the parse failure as ServerError.
+ _log.Warning(ex, "Verify config for {0} could not be parsed", cmd.ConnectionName);
+ Sender.Tell(new VerifyEndpointResult(
+ false, VerifyFailureKind.ServerError,
+ "The endpoint configuration could not be parsed.", null));
+ return;
+ }
+
+ var probeLogger = NullLogger.Instance;
+ RealOpcUaClient
+ .VerifyEndpointAsync(config, _opcUaGlobalOptions, probeLogger, TimeSpan.FromSeconds(6), CancellationToken.None)
+ .PipeTo(Sender);
+ }
+
private void HandleRemoveConnection(RemoveConnectionCommand command)
{
if (_connectionActors.TryGetValue(command.ConnectionName, out var actor))
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs
index 322e118f..adfd66a5 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs
@@ -5,7 +5,9 @@ using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
@@ -155,6 +157,230 @@ public class RealOpcUaClient : IOpcUaClient
await _subscription.CreateAsync(cancellationToken);
}
+ ///
+ /// T17: Probes an OPC UA endpoint configuration WITHOUT persisting it or creating a
+ /// long-lived connection — connect, capture the server certificate if it is untrusted,
+ /// then disconnect. The probe is secure-by-default and READ-ONLY: it forces
+ /// AutoAcceptUntrustedCertificates = false and a validation hook that captures an
+ /// untrusted server certificate then REJECTS it (e.Accept = false). It never trusts
+ /// the certificate — trusting is a separate, later operator action. The session is always
+ /// disposed in a finally.
+ ///
+ /// The endpoint configuration to probe.
+ /// Deployment-wide OPC UA application identity / cert-store paths.
+ /// Logger for diagnostics.
+ /// Wall-clock budget for the whole probe (discovery + session create).
+ /// External cancellation token, linked with the timeout.
+ /// A structured classifying the outcome.
+ public static async Task VerifyEndpointAsync(
+ OpcUaEndpointConfig config,
+ OpcUaGlobalOptions globalOptions,
+ ILogger logger,
+ TimeSpan timeout,
+ CancellationToken ct)
+ {
+ // Captured by the certificate-validation hook below. A non-null value here means
+ // the server presented an untrusted certificate; it dominates the outcome mapping.
+ X509Certificate2? capturedCert = null;
+ ISession? session = null;
+ Exception? failure = null;
+
+ var endpointUrl = string.IsNullOrWhiteSpace(config.EndpointUrl)
+ ? "opc.tcp://localhost:4840"
+ : config.EndpointUrl;
+
+ var preferredSecurityMode = config.SecurityMode switch
+ {
+ OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
+ OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
+ _ => MessageSecurityMode.None
+ };
+
+ // T17: secure-by-default — force AutoAccept=false so an untrusted server cert is
+ // captured and rejected rather than silently accepted (defeating the whole probe).
+ var appConfig = new ApplicationConfiguration
+ {
+ ApplicationName = string.IsNullOrWhiteSpace(globalOptions.ApplicationName)
+ ? "ScadaBridge-DCL"
+ : globalOptions.ApplicationName,
+ ApplicationType = ApplicationType.Client,
+ SecurityConfiguration = new SecurityConfiguration
+ {
+ AutoAcceptUntrustedCertificates = false,
+ ApplicationCertificate = new CertificateIdentifier(),
+ TrustedIssuerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(globalOptions.TrustedIssuerStorePath, "issuers") },
+ TrustedPeerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(globalOptions.TrustedPeerStorePath, "trusted") },
+ RejectedCertificateStore = new CertificateTrustList { StorePath = ResolveStorePath(globalOptions.RejectedCertificateStorePath, "rejected") }
+ },
+ ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = config.SessionTimeoutMs },
+ TransportQuotas = new TransportQuotas { OperationTimeout = config.OperationTimeoutMs }
+ };
+
+ // T17: capture the untrusted server cert, then REJECT it (e.Accept = false). The
+ // validator runs on the SDK's connect thread; copying the cert is the only state we
+ // keep. Never accept — this probe must not trust anything.
+ appConfig.CertificateValidator.CertificateValidation += (_, e) =>
+ {
+ try
+ {
+ // Copy into a stable instance so disposing the SDK's chain doesn't invalidate it.
+ capturedCert = X509CertificateLoader.LoadCertificate(e.Certificate.RawData);
+ }
+ catch
+ {
+ // Best-effort capture: fall back to the original reference if the copy fails.
+ capturedCert = e.Certificate;
+ }
+ e.Accept = false;
+ };
+
+ using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ linkedCts.CancelAfter(timeout);
+
+ try
+ {
+ await appConfig.ValidateAsync(ApplicationType.Client);
+
+ // Discover endpoints, pick the preferred security mode (same logic as ConnectAsync).
+ EndpointDescription? endpoint;
+ try
+ {
+#pragma warning disable CS0618
+ using var discoveryClient = DiscoveryClient.Create(new Uri(endpointUrl));
+ var endpoints = discoveryClient.GetEndpoints(null);
+#pragma warning restore CS0618
+ endpoint = endpoints
+ .Where(ep => ep.SecurityMode == preferredSecurityMode)
+ .FirstOrDefault() ?? endpoints.FirstOrDefault();
+ }
+ catch
+ {
+ endpoint = new EndpointDescription(endpointUrl);
+ }
+
+ var endpointConfig = EndpointConfiguration.Create(appConfig);
+ var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
+
+#pragma warning disable CS0618 // Allow obsolete DefaultSessionFactory constructor for compatibility
+ var sessionFactory = new DefaultSessionFactory();
+#pragma warning restore CS0618
+
+ var userIdentity = BuildUserIdentity(config.UserIdentity is { } ui
+ ? new OpcUaUserIdentityOptions(
+ ui.TokenType.ToString(), ui.Username, ui.Password,
+ ui.CertificatePath, ui.CertificatePassword)
+ : null);
+
+ session = await sessionFactory.CreateAsync(
+ appConfig, configuredEndpoint, false,
+ "ScadaBridge-DCL-Verify", (uint)config.SessionTimeoutMs,
+ userIdentity, null, linkedCts.Token);
+ }
+ catch (Exception ex)
+ {
+ // OperationCanceledException from the linked CTS firing on timeout is mapped to
+ // VerifyFailureKind.Timeout inside MapVerifyOutcome.
+ failure = ex;
+ logger.LogDebug(ex, "OPC UA verify of {Endpoint} failed.", endpointUrl);
+ }
+ finally
+ {
+ // T17: ALWAYS dispose the probe session — never leave a connection open.
+ if (session != null)
+ {
+ try { await session.CloseAsync(CancellationToken.None); }
+ catch (Exception ex) { logger.LogDebug(ex, "OPC UA verify session close failed (ignored)."); }
+ session.Dispose();
+ }
+ }
+
+ return MapVerifyOutcome(failure, capturedCert);
+ }
+
+ ///
+ /// T17: Pure mapping of a probe outcome — an optional exception plus an optionally
+ /// captured untrusted server certificate — to a .
+ /// Factored out so the classification is unit-testable WITHOUT a live OPC UA server.
+ /// Precedence: a captured certificate ALWAYS yields
+ /// ; otherwise the exception is
+ /// classified; null exception + null cert means the session was created (success).
+ ///
+ /// The exception thrown during the probe, or null on success.
+ /// The untrusted server certificate captured by the validation hook, or null.
+ /// The classified verification result.
+ internal static VerifyEndpointResult MapVerifyOutcome(Exception? failure, X509Certificate2? capturedCert)
+ {
+ // An untrusted server certificate dominates — regardless of how the connect failed,
+ // this is the actionable case (the operator may choose to trust it later).
+ if (capturedCert != null)
+ {
+ var info = new ServerCertInfo(
+ capturedCert.Thumbprint,
+ capturedCert.Subject,
+ capturedCert.Issuer,
+ capturedCert.NotBefore.ToUniversalTime(),
+ capturedCert.NotAfter.ToUniversalTime(),
+ Convert.ToBase64String(capturedCert.RawData));
+ return new VerifyEndpointResult(
+ false, VerifyFailureKind.UntrustedCertificate,
+ "The server certificate is not trusted by this site.", info);
+ }
+
+ if (failure is null)
+ return new VerifyEndpointResult(true, null, null, null);
+
+ // Timeout / cancellation (the linked CTS fired, or the SDK reported a request timeout).
+ if (failure is TimeoutException or OperationCanceledException)
+ return new VerifyEndpointResult(false, VerifyFailureKind.Timeout, failure.Message, null);
+
+ if (failure is ServiceResultException sre)
+ {
+ // A socket cause wrapped inside the SDK exception means the host is unreachable.
+ if (HasSocketCause(sre))
+ return new VerifyEndpointResult(false, VerifyFailureKind.Unreachable, sre.Message, null);
+
+ switch (sre.StatusCode)
+ {
+ case StatusCodes.BadRequestTimeout:
+ case StatusCodes.BadTimeout:
+ return new VerifyEndpointResult(false, VerifyFailureKind.Timeout, sre.Message, null);
+ case StatusCodes.BadUserAccessDenied:
+ case StatusCodes.BadIdentityTokenRejected:
+ case StatusCodes.BadIdentityTokenInvalid:
+ return new VerifyEndpointResult(false, VerifyFailureKind.AuthFailed, sre.Message, null);
+ case StatusCodes.BadConnectionRejected:
+ case StatusCodes.BadNotConnected:
+ case StatusCodes.BadConnectionClosed:
+ case StatusCodes.BadNoCommunication:
+ case StatusCodes.BadServerNotConnected:
+ return new VerifyEndpointResult(false, VerifyFailureKind.Unreachable, sre.Message, null);
+ default:
+ return new VerifyEndpointResult(false, VerifyFailureKind.ServerError, sre.Message, null);
+ }
+ }
+
+ // A bare socket failure (DNS / connection refused) before the SDK wrapped it.
+ if (HasSocketCause(failure))
+ return new VerifyEndpointResult(false, VerifyFailureKind.Unreachable, failure.Message, null);
+
+ return new VerifyEndpointResult(false, VerifyFailureKind.ServerError, failure.Message, null);
+ }
+
+ ///
+ /// Walks the exception's InnerException chain looking for a
+ /// — the signature of a DNS-resolution
+ /// or connection-refused failure that means the endpoint host is unreachable.
+ ///
+ private static bool HasSocketCause(Exception ex)
+ {
+ for (var cur = ex; cur != null; cur = cur.InnerException)
+ {
+ if (cur is System.Net.Sockets.SocketException)
+ return true;
+ }
+ return false;
+ }
+
///
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
{
diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs
index 90461c9c..76f5b5fa 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs
@@ -787,9 +787,15 @@ akka {{
{
var healthCollector = _serviceProvider.GetRequiredService();
var siteEventLogger = _serviceProvider.GetService();
+ // T17: the verify-endpoint probe builds an OPC UA ApplicationConfiguration directly,
+ // so the manager needs the same deployment-wide OpcUaGlobalOptions the
+ // DataConnectionFactory feeds to RealOpcUaClient when creating connections.
+ var opcUaGlobalOptions = _serviceProvider
+ .GetService>()?.Value
+ ?? new ZB.MOM.WW.ScadaBridge.DataConnectionLayer.OpcUaGlobalOptions();
dclManager = _actorSystem!.ActorOf(
Props.Create(() => new ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors.DataConnectionManagerActor(
- dclFactory, dclOptions, healthCollector, siteEventLogger)),
+ dclFactory, dclOptions, healthCollector, siteEventLogger, opcUaGlobalOptions)),
"dcl-manager");
_logger.LogInformation("Data Connection Layer manager actor created");
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/VerifyEndpointResultMappingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/VerifyEndpointResultMappingTests.cs
new file mode 100644
index 00000000..0e8ac256
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/VerifyEndpointResultMappingTests.cs
@@ -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;
+
+///
+/// T17: Unit tests for — the pure
+/// (exception + captured cert) → mapping that backs the
+/// OPC UA verify-endpoint probe. These exercise every
+/// branch WITHOUT a live OPC UA server, so the classification logic is covered
+/// deterministically.
+///
+public class VerifyEndpointResultMappingTests
+{
+ ///
+ /// Builds a throwaway self-signed certificate so the untrusted-cert branch can be
+ /// exercised with a real (and real DER bytes) without a
+ /// live server. Not persisted; only its public fields are asserted.
+ ///
+ 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);
+ }
+}