From 733c7bf66cb3c398e6e94d30983d4ef7a21e918c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 03:00:55 -0400 Subject: [PATCH] feat(dcl): OPC UA verify-endpoint probe with untrusted-cert capture (T17) --- .../Management/VerifyEndpointCommands.cs | 80 +++++++ .../Actors/DataConnectionManagerActor.cs | 59 ++++- .../Adapters/RealOpcUaClient.cs | 226 ++++++++++++++++++ .../Actors/AkkaHostedService.cs | 8 +- .../VerifyEndpointResultMappingTests.cs | 180 ++++++++++++++ 5 files changed, 551 insertions(+), 2 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/VerifyEndpointCommands.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/VerifyEndpointResultMappingTests.cs 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); + } +}