From 72f32045a4df05a2880a63c552f9f1044952a92c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 12 Jun 2026 11:51:53 -0400 Subject: [PATCH] refactor(historian): remove named-pipe transport --- .../WonderwareHistorianClientOptions.cs | 14 +- .../Internal/FrameChannel.cs | 21 +- .../WonderwareHistorianClient.cs | 12 +- .../Ipc/IFrameHandler.cs | 20 + .../Ipc/PipeAcl.cs | 39 -- .../Ipc/PipeServer.cs | 258 ------------- .../Ipc/TcpFrameServer.cs | 6 +- .../HistorianWonderwareDriverPage.razor | 26 +- src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 6 +- .../Historian/AlarmHistorianOptions.cs | 5 +- .../TcpConnectFactoryTests.cs | 12 +- .../WonderwareHistorianClientOptionsTests.cs | 10 +- .../WonderwareHistorianClientTests.cs | 15 +- .../Ipc/PipeRoundTripTests.cs | 348 ------------------ .../Ipc/PipeServerSidRejectTests.cs | 90 ----- ...derwareDriverPageFormSerializationTests.cs | 21 +- 16 files changed, 84 insertions(+), 819 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/IFrameHandler.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/PipeAcl.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/PipeServer.cs delete mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/PipeRoundTripTests.cs delete mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/PipeServerSidRejectTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/WonderwareHistorianClientOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/WonderwareHistorianClientOptions.cs index 47f0aa68..4e7c7257 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/WonderwareHistorianClientOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/WonderwareHistorianClientOptions.cs @@ -15,13 +15,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client; /// history router are expected to layer their own backoff on top. /// /// -/// Named-pipe name the sidecar listens on (matches the sidecar's OTOPCUA_HISTORIAN_PIPE). +/// Sidecar TCP host (DNS name or IP) the client dials. +/// Sidecar TCP port (matches the sidecar's OTOPCUA_HISTORIAN_TCP_PORT). /// Per-process shared secret the sidecar will verify in the Hello frame. /// Diagnostic peer identifier sent in Hello — typically the OtOpcUa instance id. -/// Cap on the named-pipe connect + Hello round trip on each (re)connect. +/// Cap on the TCP connect + Hello round trip on each (re)connect. /// Cap on a single read/write call once connected. public sealed record WonderwareHistorianClientOptions( - string PipeName, + string Host, + int Port, string SharedSecret, string PeerName = "OtOpcUa", TimeSpan? ConnectTimeout = null, @@ -41,12 +43,6 @@ public sealed record WonderwareHistorianClientOptions( [Range(1, 60)] public int ProbeTimeoutSeconds { get; init; } = 15; - /// Sidecar TCP host (DNS name or IP). Required for the TCP transport. - public string? Host { get; init; } - - /// Sidecar TCP port (matches the sidecar's OTOPCUA_HISTORIAN_TCP_PORT). - public int Port { get; init; } - /// When true, the client wraps the TCP stream in TLS before the Hello handshake. public bool UseTls { get; init; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Internal/FrameChannel.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Internal/FrameChannel.cs index 0e27e482..98991f91 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Internal/FrameChannel.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Internal/FrameChannel.cs @@ -1,4 +1,3 @@ -using System.IO.Pipes; using System.Net.Security; using System.Net.Sockets; using System.Security.Authentication; @@ -31,22 +30,6 @@ internal sealed class FrameChannel : IAsyncDisposable private FrameWriter? _writer; private bool _disposed; - /// - /// Default factory: connects to a real by name. - /// - public static Func> DefaultNamedPipeConnectFactory = - async (opts, ct) => - { - var pipe = new NamedPipeClientStream( - serverName: ".", - pipeName: opts.PipeName, - direction: PipeDirection.InOut, - options: PipeOptions.Asynchronous); - - await pipe.ConnectAsync((int)opts.EffectiveConnectTimeout.TotalMilliseconds, ct).ConfigureAwait(false); - return pipe; - }; - /// /// Default TCP factory: connects to the sidecar over TCP, optionally wrapping the stream /// in TLS (server-auth; pinned-thumbprint or CA-chain validation). The Hello handshake + @@ -63,7 +46,7 @@ internal sealed class FrameChannel : IAsyncDisposable { using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(ct); connectCts.CancelAfter(opts.EffectiveConnectTimeout); - await tcp.ConnectAsync(opts.Host!, opts.Port, connectCts.Token).ConfigureAwait(false); + await tcp.ConnectAsync(opts.Host, opts.Port, connectCts.Token).ConfigureAwait(false); } catch { @@ -85,7 +68,7 @@ internal sealed class FrameChannel : IAsyncDisposable }); try { - await ssl.AuthenticateAsClientAsync(opts.Host!).ConfigureAwait(false); + await ssl.AuthenticateAsClientAsync(opts.Host).ConfigureAwait(false); } catch { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs index 3f49a6f3..481b55c8 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs @@ -527,12 +527,12 @@ public sealed class WonderwareHistorianClient : IHistorianDataSource, IAlarmHist /// /// Synchronous dispose required by on - /// . The underlying channel's async cleanup runs - /// teardown, which can block briefly - /// on OS handle release — strictly speaking it is not non-blocking — but the - /// GetAwaiter()/GetResult() bridge is deadlock-safe because the cleanup never - /// awaits a captured nor takes any - /// lock that the caller could hold. (Finding 010.) + /// . The underlying channel's async cleanup runs the + /// TCP socket teardown, which can block briefly on OS handle release — strictly speaking + /// it is not non-blocking — but the GetAwaiter()/GetResult() bridge is + /// deadlock-safe because the cleanup never awaits a captured + /// nor takes any lock that the + /// caller could hold. (Finding 010.) /// public void Dispose() => _channel.DisposeAsync().AsTask().GetAwaiter().GetResult(); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/IFrameHandler.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/IFrameHandler.cs new file mode 100644 index 00000000..e00e606c --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/IFrameHandler.cs @@ -0,0 +1,20 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc; + +/// +/// Strategy for handling each post-Hello frame the sidecar's +/// reads. Implementations deserialize the body per the , dispatch +/// to the historian, and write the corresponding reply through the supplied +/// . +/// +public interface IFrameHandler +{ + /// Handles a frame from the sidecar frame server. + /// The type of message being handled. + /// The serialized message body. + /// The frame writer to send responses. + /// Cancellation token for the operation. + Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/PipeAcl.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/PipeAcl.cs deleted file mode 100644 index 4d6094f6..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/PipeAcl.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using System.IO.Pipes; -using System.Security.AccessControl; -using System.Security.Principal; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc; - -/// -/// Builds a strict for the historian sidecar pipe — only the -/// configured server-principal SID gets ReadWrite | Synchronize, LocalSystem is -/// explicitly denied (unless it's the allowed principal itself), and the allowed SID owns -/// the DACL. Mirrors the policy in Driver.Galaxy.Host's PipeAcl. -/// -public static class PipeAcl -{ - /// Creates a strict PipeSecurity for the historian sidecar pipe. - /// The security identifier that should have read-write access to the pipe. - /// A configured PipeSecurity object with strict access control. - public static PipeSecurity Create(SecurityIdentifier allowedSid) - { - if (allowedSid is null) throw new ArgumentNullException(nameof(allowedSid)); - - var security = new PipeSecurity(); - - security.AddAccessRule(new PipeAccessRule( - allowedSid, - PipeAccessRights.ReadWrite | PipeAccessRights.Synchronize, - AccessControlType.Allow)); - - var localSystem = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); - if (allowedSid != localSystem) - security.AddAccessRule(new PipeAccessRule(localSystem, PipeAccessRights.FullControl, AccessControlType.Deny)); - - // Owner = allowed SID so the deny rules can't be removed without write-DACL rights. - security.SetOwner(allowedSid); - - return security; - } -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/PipeServer.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/PipeServer.cs deleted file mode 100644 index ffd9cb0c..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/PipeServer.cs +++ /dev/null @@ -1,258 +0,0 @@ -using System; -using System.IO.Pipes; -using System.Security.Principal; -using System.Threading; -using System.Threading.Tasks; -using MessagePack; -using Serilog; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc; - -/// -/// Accepts one client connection at a time on a named pipe with the strict ACL from -/// . Verifies the peer SID and the per-process shared secret before -/// any frame is dispatched. Mirrors Driver.Galaxy.Host's PipeServer; the sidecar carries -/// its own copy so the deletion of Galaxy.Host in PR 7.2 leaves the sidecar self-contained. -/// -public sealed class PipeServer : IDisposable -{ - private readonly string _pipeName; - private readonly SecurityIdentifier _allowedSid; - private readonly string _sharedSecret; - private readonly ILogger _logger; - private readonly CancellationTokenSource _cts = new(); - private readonly CallerVerifier _verifier; - private NamedPipeServerStream? _current; - - /// - /// Pluggable caller-verification seam. Default implementation calls - /// ; tests can substitute one that ignores the pipe ACL - /// to exercise the rejection paths. - /// - /// The named pipe server stream to verify. - /// The allowed security identifier. - /// The rejection reason if verification fails. - internal delegate bool CallerVerifier(NamedPipeServerStream pipe, SecurityIdentifier allowedSid, out string reason); - - /// Initializes a new instance of the class. - /// The name of the named pipe. - /// The security identifier allowed to connect. - /// The shared secret for client authentication. - /// The logger for diagnostic messages. - public PipeServer(string pipeName, SecurityIdentifier allowedSid, string sharedSecret, ILogger logger) - : this(pipeName, allowedSid, sharedSecret, logger, DefaultVerifier) { } - - /// Initializes a new instance of the class with a custom verifier. - /// The name of the named pipe. - /// The security identifier allowed to connect. - /// The shared secret for client authentication. - /// The logger for diagnostic messages. - /// The caller verification delegate. - internal PipeServer( - string pipeName, SecurityIdentifier allowedSid, string sharedSecret, ILogger logger, - CallerVerifier verifier) - { - _pipeName = pipeName ?? throw new ArgumentNullException(nameof(pipeName)); - _allowedSid = allowedSid ?? throw new ArgumentNullException(nameof(allowedSid)); - _sharedSecret = sharedSecret ?? throw new ArgumentNullException(nameof(sharedSecret)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _verifier = verifier ?? throw new ArgumentNullException(nameof(verifier)); - } - - private static bool DefaultVerifier(NamedPipeServerStream pipe, SecurityIdentifier allowedSid, out string reason) - => VerifyCaller(pipe, allowedSid, out reason); - - /// - /// Accepts one connection, performs Hello handshake, then dispatches frames to - /// until EOF or cancel. Returns when the client disconnects. - /// - /// The frame handler to process frames. - /// Cancellation token for the operation. - public async Task RunOneConnectionAsync(IFrameHandler handler, CancellationToken ct) - { - using var linked = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, ct); - var acl = PipeAcl.Create(_allowedSid); - - _current = new NamedPipeServerStream( - _pipeName, - PipeDirection.InOut, - maxNumberOfServerInstances: 1, - PipeTransmissionMode.Byte, - PipeOptions.Asynchronous, - inBufferSize: 64 * 1024, - outBufferSize: 64 * 1024, - pipeSecurity: acl); - - try - { - await _current.WaitForConnectionAsync(linked.Token).ConfigureAwait(false); - - using var reader = new FrameReader(_current, leaveOpen: true); - using var writer = new FrameWriter(_current, leaveOpen: true); - - // First frame must be Hello with the correct shared secret. Reading it before - // the caller-SID impersonation check satisfies Windows' ERROR_CANNOT_IMPERSONATE - // rule — ImpersonateNamedPipeClient fails until at least one frame has been read. - var first = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false); - if (first is null || first.Value.Kind != MessageKind.Hello) - { - _logger.Warning("Sidecar IPC first frame was not Hello; dropping"); - return; - } - - if (!_verifier(_current, _allowedSid, out var reason)) - { - // Driver.Historian.Wonderware-007: send a rejecting HelloAck so the client - // learns why instead of having to wait for its own read timeout. The reason - // tag "caller-sid-mismatch" is symmetric with the shared-secret-mismatch and - // major-version-mismatch acks the two other rejection paths emit below. - await writer.WriteAsync(MessageKind.HelloAck, - new HelloAck { Accepted = false, RejectReason = $"caller-sid-mismatch: {reason}" }, - linked.Token).ConfigureAwait(false); - _logger.Warning("Sidecar IPC caller rejected: {Reason}", reason); - _current.Disconnect(); - return; - } - - var hello = MessagePackSerializer.Deserialize(first.Value.Body); - if (!string.Equals(hello.SharedSecret, _sharedSecret, StringComparison.Ordinal)) - { - await writer.WriteAsync(MessageKind.HelloAck, - new HelloAck { Accepted = false, RejectReason = "shared-secret-mismatch" }, - linked.Token).ConfigureAwait(false); - _logger.Warning("Sidecar IPC Hello rejected: shared-secret-mismatch"); - return; - } - - if (hello.ProtocolMajor != Hello.CurrentMajor) - { - await writer.WriteAsync(MessageKind.HelloAck, - new HelloAck { Accepted = false, RejectReason = $"major-version-mismatch-peer={hello.ProtocolMajor}-server={Hello.CurrentMajor}" }, - linked.Token).ConfigureAwait(false); - _logger.Warning("Sidecar IPC Hello rejected: major mismatch peer={Peer} server={Server}", - hello.ProtocolMajor, Hello.CurrentMajor); - return; - } - - await writer.WriteAsync(MessageKind.HelloAck, - new HelloAck { Accepted = true, HostName = Environment.MachineName }, - linked.Token).ConfigureAwait(false); - - while (!linked.Token.IsCancellationRequested) - { - var frame = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false); - if (frame is null) break; - - await handler.HandleAsync(frame.Value.Kind, frame.Value.Body, writer, linked.Token).ConfigureAwait(false); - } - } - finally - { - _current.Dispose(); - _current = null; - } - } - - // Backoff sequence for consecutive RunOneConnection failures: 250 ms → 500 ms → - // 1 000 ms → 2 000 ms → 4 000 ms → capped at 8 000 ms thereafter. - private static readonly TimeSpan[] BackoffSteps = - { - TimeSpan.FromMilliseconds(250), - TimeSpan.FromMilliseconds(500), - TimeSpan.FromSeconds(1), - TimeSpan.FromSeconds(2), - TimeSpan.FromSeconds(4), - TimeSpan.FromSeconds(8), - }; - - /// - /// Maximum consecutive failures before the server gives up and lets the process exit - /// so the supervisor (NSSM / SCM) can restart the sidecar cleanly. - /// - private const int MaxConsecutiveFailures = 20; - - /// - /// Runs the server continuously, handling one connection at a time. When a connection - /// ends (clean or error), waits with exponential backoff before accepting the next. - /// If consecutive failures occur the method - /// throws so the supervisor can restart the sidecar. - /// - /// The frame handler to process frames. - /// Cancellation token for the operation. - public async Task RunAsync(IFrameHandler handler, CancellationToken ct) - { - var consecutiveFailures = 0; - - while (!ct.IsCancellationRequested) - { - try - { - await RunOneConnectionAsync(handler, ct).ConfigureAwait(false); - consecutiveFailures = 0; // a clean connection (even a short-lived one) resets the counter - } - catch (OperationCanceledException) { break; } - catch (Exception ex) - { - consecutiveFailures++; - - if (consecutiveFailures >= MaxConsecutiveFailures) - { - _logger.Fatal(ex, - "Sidecar IPC connection loop failed {Count} consecutive times — giving up so supervisor can restart", - consecutiveFailures); - throw; - } - - var delay = BackoffSteps[Math.Min(consecutiveFailures - 1, BackoffSteps.Length - 1)]; - _logger.Error(ex, - "Sidecar IPC connection loop error (consecutive failure {Count}/{Max}) — retrying in {Delay}", - consecutiveFailures, MaxConsecutiveFailures, delay); - - try { await Task.Delay(delay, ct).ConfigureAwait(false); } - catch (OperationCanceledException) { break; } - } - } - } - - private static bool VerifyCaller(NamedPipeServerStream pipe, SecurityIdentifier allowedSid, out string reason) - { - try - { - pipe.RunAsClient(() => - { - using var wi = WindowsIdentity.GetCurrent(); - if (wi.User is null) - throw new InvalidOperationException("GetCurrent().User is null — cannot verify caller"); - if (wi.User != allowedSid) - throw new UnauthorizedAccessException( - $"caller SID {wi.User.Value} does not match allowed {allowedSid.Value}"); - }); - reason = string.Empty; - return true; - } - catch (Exception ex) { reason = ex.Message; return false; } - } - - /// Disposes the pipe server and cancels any pending operations. - public void Dispose() - { - _cts.Cancel(); - _current?.Dispose(); - _cts.Dispose(); - } -} - -/// -/// Strategy for handling each post-Hello frame the pipe server reads. Implementations -/// deserialize the body per the , dispatch to the historian, and -/// write the corresponding reply through the supplied . -/// -public interface IFrameHandler -{ - /// Handles a frame from the pipe server. - /// The type of message being handled. - /// The serialized message body. - /// The frame writer to send responses. - /// Cancellation token for the operation. - Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct); -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/TcpFrameServer.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/TcpFrameServer.cs index cdaa4919..a4c0c217 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/TcpFrameServer.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/TcpFrameServer.cs @@ -14,8 +14,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc; /// /// Accepts one TCP client at a time, optionally over TLS, verifies the shared-secret -/// Hello, then dispatches frames to . The TCP replacement for -/// PipeServer; the Windows-SID ACL is replaced by TLS + the shared secret. +/// Hello, then dispatches frames to . Authentication is the +/// shared secret carried in the Hello frame, optionally over a TLS-protected channel. /// public sealed class TcpFrameServer : IDisposable { @@ -125,7 +125,7 @@ public sealed class TcpFrameServer : IDisposable } } - // ---- identical backoff/give-up policy to PipeServer (copy verbatim) ---- + // ---- exponential backoff / give-up policy between accepted connections ---- private static readonly TimeSpan[] BackoffSteps = { TimeSpan.FromMilliseconds(250), TimeSpan.FromMilliseconds(500), TimeSpan.FromSeconds(1), diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor index 871415d5..333ba75b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor @@ -66,11 +66,16 @@ else
Connection
-
- - -
Must match the sidecar's OTOPCUA_HISTORIAN_PIPE environment variable.
+
+ + +
DNS name or IP the historian sidecar's TCP listener is reachable at.
+
+
+ + +
Must match the sidecar's OTOPCUA_HISTORIAN_TCP_PORT.
@@ -209,7 +214,7 @@ else } private static WonderwareHistorianClientOptions CreateDefaultOptions() => - new(PipeName: "otopcua-historian", SharedSecret: ""); + new(Host: "localhost", Port: 32569, SharedSecret: ""); private async Task SubmitAsync() { @@ -309,7 +314,8 @@ else ///
public sealed class WonderwareHistorianClientFormModel { - public string PipeName { get; set; } = "otopcua-historian"; + public string Host { get; set; } = "localhost"; + public int Port { get; set; } = 32569; public string SharedSecret { get; set; } = ""; public string PeerName { get; set; } = "OtOpcUa"; public int? ConnectTimeoutSeconds { get; set; } @@ -318,7 +324,8 @@ else public static WonderwareHistorianClientFormModel FromRecord(WonderwareHistorianClientOptions r) => new() { - PipeName = r.PipeName, + Host = r.Host, + Port = r.Port, SharedSecret = r.SharedSecret, PeerName = r.PeerName, ConnectTimeoutSeconds = r.ConnectTimeout.HasValue ? (int)r.ConnectTimeout.Value.TotalSeconds : null, @@ -327,7 +334,8 @@ else }; public WonderwareHistorianClientOptions ToRecord() => new( - PipeName: PipeName, + Host: Host, + Port: Port, SharedSecret: SharedSecret, PeerName: PeerName, ConnectTimeout: ConnectTimeoutSeconds.HasValue ? TimeSpan.FromSeconds(ConnectTimeoutSeconds.Value) : null, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index ed042f26..f2d36aea 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -88,15 +88,15 @@ if (hasDriver) // Config-gated durable alarm-historian sink. When the AlarmHistorian section is enabled this // overrides the NullAlarmHistorianSink default from AddOtOpcUaRuntime (last registration wins) - // with a SqliteStoreAndForwardSink draining to the Wonderware named-pipe writer. The writer is + // with a SqliteStoreAndForwardSink draining to the Wonderware TCP writer. The writer is // injected here because the Host is the only project that references the Wonderware client — // Runtime owns the gating + Sqlite construction, the Host supplies the concrete downstream. builder.Services.AddAlarmHistorian( builder.Configuration, (opts, sp) => new WonderwareHistorianClient( - new WonderwareHistorianClientOptions(opts.PipeName, opts.SharedSecret) + new WonderwareHistorianClientOptions(opts.Host, opts.Port, opts.SharedSecret) { - Host = opts.Host, Port = opts.Port, UseTls = opts.UseTls, ServerCertThumbprint = opts.ServerCertThumbprint, + UseTls = opts.UseTls, ServerCertThumbprint = opts.ServerCertThumbprint, }, sp.GetService>())); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs index aeaa33de..5bdb8096 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs @@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian; /// Binds the AlarmHistorian configuration section that gates the durable /// store-and-forward alarm sink. When is true, /// AddAlarmHistorian registers a SqliteStoreAndForwardSink (draining to the -/// Wonderware named-pipe writer supplied by the Host) in place of the +/// Wonderware TCP writer supplied by the Host) in place of the /// NullAlarmHistorianSink default; otherwise the Null default survives. /// public sealed class AlarmHistorianOptions @@ -25,9 +25,6 @@ public sealed class AlarmHistorianOptions /// Filesystem path to the local SQLite store-and-forward queue database. public string DatabasePath { get; init; } = "alarm-historian.db"; - /// Named-pipe name the Wonderware historian sidecar listens on. - public string PipeName { get; init; } = "OtOpcUaHistorian"; - /// TCP hostname or IP address the Wonderware historian sidecar listens on. public string Host { get; init; } = "localhost"; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/TcpConnectFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/TcpConnectFactoryTests.cs index a85521dc..e9ba17ef 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/TcpConnectFactoryTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/TcpConnectFactoryTests.cs @@ -55,10 +55,8 @@ public sealed class TcpConnectFactoryTests await Task.Delay(TimeSpan.FromMilliseconds(200), cts.Token); }, cts.Token); - var opts = new WonderwareHistorianClientOptions("pipe", "secret") + var opts = new WonderwareHistorianClientOptions("127.0.0.1", boundPort, "secret") { - Host = "127.0.0.1", - Port = boundPort, UseTls = false, }; @@ -94,10 +92,8 @@ public sealed class TcpConnectFactoryTests ssl.Dispose(); }, cts.Token); - var opts = new WonderwareHistorianClientOptions("pipe", "secret") + var opts = new WonderwareHistorianClientOptions("127.0.0.1", boundPort, "secret") { - Host = "127.0.0.1", - Port = boundPort, UseTls = true, ServerCertThumbprint = cert.GetCertHashString(), }; @@ -137,10 +133,8 @@ public sealed class TcpConnectFactoryTests } }, cts.Token); - var opts = new WonderwareHistorianClientOptions("pipe", "secret") + var opts = new WonderwareHistorianClientOptions("127.0.0.1", boundPort, "secret") { - Host = "127.0.0.1", - Port = boundPort, UseTls = true, ServerCertThumbprint = "00112233445566778899AABBCCDDEEFF00112233", // bogus }; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientOptionsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientOptionsTests.cs index 6f56032c..8e8b8fb4 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientOptionsTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientOptionsTests.cs @@ -11,10 +11,8 @@ public sealed class WonderwareHistorianClientOptionsTests [Fact] public void TcpTlsFields_AreStoredCorrectly_WhenExplicitlySet() { - var opts = new WonderwareHistorianClientOptions("pipe", "secret") + var opts = new WonderwareHistorianClientOptions("h", 32569, "secret") { - Host = "h", - Port = 32569, UseTls = true, ServerCertThumbprint = "AB" }; @@ -28,10 +26,10 @@ public sealed class WonderwareHistorianClientOptionsTests [Fact] public void TcpTlsFields_HaveCorrectDefaults_WhenNotSet() { - var opts = new WonderwareHistorianClientOptions("pipe", "secret"); + var opts = new WonderwareHistorianClientOptions("host", 32569, "secret"); - opts.Host.ShouldBeNull(); - opts.Port.ShouldBe(0); + opts.Host.ShouldBe("host"); + opts.Port.ShouldBe(32569); opts.UseTls.ShouldBeFalse(); opts.ServerCertThumbprint.ShouldBeNull(); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientTests.cs index bbb3c4e2..1522e091 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientTests.cs @@ -22,14 +22,13 @@ public sealed class WonderwareHistorianClientTests private const string Secret = "test-secret-123"; private static WonderwareHistorianClientOptions OptsFor(FakeSidecarServer server) => new( - PipeName: "", + Host: "127.0.0.1", + Port: server.BoundPort, SharedSecret: Secret, PeerName: "test", ConnectTimeout: TimeSpan.FromSeconds(2), CallTimeout: TimeSpan.FromSeconds(2)) { - Host = "127.0.0.1", - Port = server.BoundPort, UseTls = false, }; @@ -445,14 +444,13 @@ public sealed class WonderwareHistorianClientTests await server.StartAsync(); var opts = new WonderwareHistorianClientOptions( - PipeName: "", + Host: "127.0.0.1", + Port: server.BoundPort, SharedSecret: Secret, PeerName: "test", ConnectTimeout: TimeSpan.FromSeconds(2), CallTimeout: TimeSpan.FromMilliseconds(500)) // short timeout for test speed { - Host = "127.0.0.1", - Port = server.BoundPort, UseTls = false, }; @@ -661,13 +659,12 @@ public sealed class WonderwareHistorianClientTests // 3. Construct the client via the PUBLIC ctor (no ForTests factory). var opts = new WonderwareHistorianClientOptions( - PipeName: "ignored-pipe", + Host: "127.0.0.1", + Port: boundPort, SharedSecret: Secret, ConnectTimeout: TimeSpan.FromSeconds(5), CallTimeout: TimeSpan.FromSeconds(5)) { - Host = "127.0.0.1", - Port = boundPort, UseTls = false, }; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/PipeRoundTripTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/PipeRoundTripTests.cs deleted file mode 100644 index c62a5c07..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/PipeRoundTripTests.cs +++ /dev/null @@ -1,348 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using MessagePack; -using Serilog; -using Serilog.Core; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc; -using SidecarHistorianEventDto = ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc.HistorianEventDto; -using BackendHistorianEventDto = ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend.HistorianEventDto; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Ipc; - -/// -/// Round-trip tests for the sidecar pipe contract added in PR 3.3. Each scenario serializes -/// a Request through the wire framing, dispatches via -/// against a fake historian, and asserts the returned Reply round-trips with the expected -/// content. No real named pipe is opened — the framing is exercised over a back-to-back -/// pair so tests stay fast and platform-independent. -/// -public sealed class PipeRoundTripTests -{ - private static readonly ILogger Quiet = Logger.None; - - private sealed class FakeHistorian : IHistorianDataSource - { - /// Gets or sets the raw samples to return from reads. - public List RawSamples { get; set; } = new(); - - /// Gets or sets the aggregate samples to return from reads. - public List AggregateSamples { get; set; } = new(); - - /// Gets or sets the at-time samples to return from reads. - public List AtTimeSamples { get; set; } = new(); - - /// Gets or sets the events to return from reads. - public List Events { get; set; } = new(); - - /// Gets or sets an exception to throw from read operations. - public Exception? ThrowFromRead { get; set; } - - /// - /// Reads raw samples from the fake historian or throws if configured. - /// - /// The tag name. - /// The start time. - /// The end time. - /// The maximum number of values to return. - /// Cancellation token. - /// The raw samples. - public Task> ReadRawAsync(string tagName, DateTime startTime, DateTime endTime, int maxValues, CancellationToken ct = default) - { - if (ThrowFromRead is not null) throw ThrowFromRead; - return Task.FromResult(RawSamples); - } - - /// - /// Reads aggregate samples from the fake historian. - /// - /// The tag name. - /// The start time. - /// The end time. - /// The interval in milliseconds. - /// The aggregate column name. - /// Cancellation token. - /// The aggregate samples. - public Task> ReadAggregateAsync(string tagName, DateTime startTime, DateTime endTime, double intervalMs, string aggregateColumn, CancellationToken ct = default) - => Task.FromResult(AggregateSamples); - - /// - /// Reads at-time samples from the fake historian. - /// - /// The tag name. - /// The timestamps to read at. - /// Cancellation token. - /// The at-time samples. - public Task> ReadAtTimeAsync(string tagName, DateTime[] timestamps, CancellationToken ct = default) - => Task.FromResult(AtTimeSamples); - - /// - /// Reads events from the fake historian. - /// - /// The event source name. - /// The start time. - /// The end time. - /// The maximum number of events to return. - /// Cancellation token. - /// The events. - public Task> ReadEventsAsync(string? sourceName, DateTime startTime, DateTime endTime, int maxEvents, CancellationToken ct = default) - => Task.FromResult(Events); - - /// Gets a health snapshot of the fake historian. - /// A health snapshot. - public HistorianHealthSnapshot GetHealthSnapshot() => new(); - - /// Disposes the fake historian. - public void Dispose() { } - } - - private sealed class FakeAlarmWriter : IAlarmEventWriter - { - /// Gets the events received by this writer. - public List Received { get; } = new(); - - /// Gets or sets a delegate that decides whether each event should be marked as successfully written. - public Func Decide { get; set; } = _ => true; - - /// - /// Writes alarm events to the fake writer and returns per-event status based on the delegate. - /// - /// The events to write. - /// Cancellation token. - /// An array of booleans indicating success for each event. - public Task WriteAsync(AlarmHistorianEventDto[] events, CancellationToken cancellationToken) - { - Received.AddRange(events); - var result = new bool[events.Length]; - for (var i = 0; i < events.Length; i++) result[i] = Decide(events[i]); - return Task.FromResult(result); - } - } - - /// - /// Drives one round trip: serialize , run the handler, - /// read the reply frame, deserialize it. Returns the reply. - /// - private static async Task RoundTripAsync( - MessageKind requestKind, - MessageKind expectedReplyKind, - TRequest request, - IFrameHandler handler) - { - // Build the request body the same way FrameWriter would, but feed it directly into - // the handler's Handle method (the pipe server has already read the kind + body - // before handing them to the handler). - var requestBody = MessagePackSerializer.Serialize(request); - - using var stream = new MemoryStream(); - using var writer = new FrameWriter(stream, leaveOpen: true); - - await handler.HandleAsync(requestKind, requestBody, writer, CancellationToken.None); - - stream.Position = 0; - using var reader = new FrameReader(stream, leaveOpen: true); - var frame = await reader.ReadFrameAsync(CancellationToken.None); - frame.ShouldNotBeNull(); - frame!.Value.Kind.ShouldBe(expectedReplyKind); - return MessagePackSerializer.Deserialize(frame.Value.Body); - } - - /// Verifies that raw historian samples round-trip correctly through the frame handler. - [Fact] - public async Task ReadRaw_RoundTripsSamples() - { - var historian = new FakeHistorian(); - historian.RawSamples.Add(new HistorianSample { Value = 42.0, Quality = 192, TimestampUtc = new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Utc) }); - historian.RawSamples.Add(new HistorianSample { Value = 43.5, Quality = 192, TimestampUtc = new DateTime(2026, 4, 29, 12, 0, 1, DateTimeKind.Utc) }); - - var handler = new HistorianFrameHandler(historian, Quiet); - var reply = await RoundTripAsync( - MessageKind.ReadRawRequest, MessageKind.ReadRawReply, - new ReadRawRequest - { - TagName = "Tank.Level", - StartUtcTicks = new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc).Ticks, - EndUtcTicks = new DateTime(2026, 4, 30, 0, 0, 0, DateTimeKind.Utc).Ticks, - MaxValues = 100, - CorrelationId = "corr-1", - }, handler); - - reply.Success.ShouldBeTrue(); - reply.Error.ShouldBeNull(); - reply.CorrelationId.ShouldBe("corr-1"); - reply.Samples.Length.ShouldBe(2); - reply.Samples[0].Quality.ShouldBe((byte)192); - reply.Samples[0].TimestampUtcTicks.ShouldBe(new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Utc).Ticks); - reply.Samples[0].ValueBytes.ShouldNotBeNull(); - MessagePackSerializer.Deserialize(reply.Samples[0].ValueBytes!).ShouldBe(42.0); - } - - /// Verifies that read failures are properly surfaced as error replies. - [Fact] - public async Task ReadRaw_FailureSurfacesAsErrorReply() - { - var historian = new FakeHistorian { ThrowFromRead = new InvalidOperationException("boom") }; - var handler = new HistorianFrameHandler(historian, Quiet); - var reply = await RoundTripAsync( - MessageKind.ReadRawRequest, MessageKind.ReadRawReply, - new ReadRawRequest { TagName = "Tag", CorrelationId = "fail-1" }, handler); - - reply.Success.ShouldBeFalse(); - reply.Error.ShouldBe("boom"); - reply.CorrelationId.ShouldBe("fail-1"); - reply.Samples.ShouldBeEmpty(); - } - - /// Verifies that processed (aggregate) historian samples round-trip correctly through the frame handler. - [Fact] - public async Task ReadProcessed_RoundTripsBuckets() - { - var historian = new FakeHistorian(); - historian.AggregateSamples.Add(new HistorianAggregateSample { Value = 50.0, TimestampUtc = new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc) }); - historian.AggregateSamples.Add(new HistorianAggregateSample { Value = null, TimestampUtc = new DateTime(2026, 4, 29, 0, 1, 0, DateTimeKind.Utc) }); - - var handler = new HistorianFrameHandler(historian, Quiet); - var reply = await RoundTripAsync( - MessageKind.ReadProcessedRequest, MessageKind.ReadProcessedReply, - new ReadProcessedRequest { TagName = "Tank.Level", IntervalMs = 60000, AggregateColumn = "Average", CorrelationId = "p-1" }, - handler); - - reply.Success.ShouldBeTrue(); - reply.Buckets.Length.ShouldBe(2); - reply.Buckets[0].Value.ShouldBe(50.0); - reply.Buckets[1].Value.ShouldBeNull(); // unavailable bucket - } - - /// Verifies that at-time historian samples round-trip correctly through the frame handler. - [Fact] - public async Task ReadAtTime_RoundTripsSamples() - { - var historian = new FakeHistorian(); - historian.AtTimeSamples.Add(new HistorianSample { Value = 7, Quality = 192, TimestampUtc = new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc) }); - - var handler = new HistorianFrameHandler(historian, Quiet); - var reply = await RoundTripAsync( - MessageKind.ReadAtTimeRequest, MessageKind.ReadAtTimeReply, - new ReadAtTimeRequest - { - TagName = "Tank.Level", - TimestampsUtcTicks = new[] { new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc).Ticks }, - CorrelationId = "t-1", - }, handler); - - reply.Success.ShouldBeTrue(); - reply.Samples.Length.ShouldBe(1); - } - - /// Verifies that historian events round-trip correctly through the frame handler. - [Fact] - public async Task ReadEvents_RoundTripsEvents() - { - var historian = new FakeHistorian(); - var eid = Guid.Parse("11111111-1111-1111-1111-111111111111"); - historian.Events.Add(new BackendHistorianEventDto - { - Id = eid, - Source = "Tank.HiHi", - EventTime = new DateTime(2026, 4, 29, 1, 0, 0, DateTimeKind.Utc), - ReceivedTime = new DateTime(2026, 4, 29, 1, 0, 1, DateTimeKind.Utc), - DisplayText = "Level high-high", - Severity = 800, - }); - - var handler = new HistorianFrameHandler(historian, Quiet); - var reply = await RoundTripAsync( - MessageKind.ReadEventsRequest, MessageKind.ReadEventsReply, - new ReadEventsRequest { SourceName = "Tank.HiHi", MaxEvents = 100, CorrelationId = "e-1" }, - handler); - - reply.Success.ShouldBeTrue(); - reply.Events.Length.ShouldBe(1); - reply.Events[0].EventId.ShouldBe(eid.ToString()); - reply.Events[0].Source.ShouldBe("Tank.HiHi"); - reply.Events[0].DisplayText.ShouldBe("Level high-high"); - reply.Events[0].Severity.ShouldBe((ushort)800); - } - - /// Verifies that alarm events are routed to the writer and per-event status is returned. - [Fact] - public async Task WriteAlarmEvents_RoutesToWriter_AndReturnsPerEventStatus() - { - var historian = new FakeHistorian(); - var alarmWriter = new FakeAlarmWriter - { - // Simulate "second event fails" to verify per-event status flows through. - Decide = e => e.EventId != "ev-2", - }; - var handler = new HistorianFrameHandler(historian, Quiet, alarmWriter); - - var request = new WriteAlarmEventsRequest - { - CorrelationId = "wa-1", - Events = new[] - { - new AlarmHistorianEventDto { EventId = "ev-1", SourceName = "Tank.HiHi", AlarmType = "Active", Severity = 800, EventTimeUtcTicks = DateTime.UtcNow.Ticks }, - new AlarmHistorianEventDto { EventId = "ev-2", SourceName = "Tank.HiHi", AlarmType = "Acknowledged", Severity = 800, EventTimeUtcTicks = DateTime.UtcNow.Ticks }, - }, - }; - - var reply = await RoundTripAsync( - MessageKind.WriteAlarmEventsRequest, MessageKind.WriteAlarmEventsReply, - request, handler); - - reply.Success.ShouldBeTrue(); - reply.PerEventOk.Length.ShouldBe(2); - reply.PerEventOk[0].ShouldBeTrue(); - reply.PerEventOk[1].ShouldBeFalse(); - alarmWriter.Received.Count.ShouldBe(2); - } - - /// Verifies that writing alarm events fails cleanly when no writer is configured. - [Fact] - public async Task WriteAlarmEvents_FailsCleanly_WhenNoWriterConfigured() - { - var historian = new FakeHistorian(); - var handler = new HistorianFrameHandler(historian, Quiet, alarmWriter: null); - - var reply = await RoundTripAsync( - MessageKind.WriteAlarmEventsRequest, MessageKind.WriteAlarmEventsReply, - new WriteAlarmEventsRequest - { - CorrelationId = "wa-2", - Events = new[] { new AlarmHistorianEventDto { EventId = "ev-1" } }, - }, handler); - - reply.Success.ShouldBeFalse(); - reply.Error.ShouldNotBeNull(); - reply.PerEventOk.Length.ShouldBe(1); - reply.PerEventOk[0].ShouldBeFalse(); - } - - /// Verifies that frame reader and writer preserve message kind and body through a round trip. - [Fact] - public async Task FrameReader_FrameWriter_RoundTripPreservesKindAndBody() - { - // Pure framing-layer test — confirms the length-prefix + kind-byte + body protocol - // is the same on both sides without any handler in the loop. - using var stream = new MemoryStream(); - using var writer = new FrameWriter(stream, leaveOpen: true); - - var hello = new Hello { ProtocolMajor = 1, PeerName = "test-peer", SharedSecret = "secret" }; - await writer.WriteAsync(MessageKind.Hello, hello, CancellationToken.None); - - stream.Position = 0; - using var reader = new FrameReader(stream, leaveOpen: true); - var frame = await reader.ReadFrameAsync(CancellationToken.None); - - frame.ShouldNotBeNull(); - frame!.Value.Kind.ShouldBe(MessageKind.Hello); - var decoded = MessagePackSerializer.Deserialize(frame.Value.Body); - decoded.PeerName.ShouldBe("test-peer"); - decoded.SharedSecret.ShouldBe("secret"); - } -} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/PipeServerSidRejectTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/PipeServerSidRejectTests.cs deleted file mode 100644 index 5381db78..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/PipeServerSidRejectTests.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System; -using System.IO.Pipes; -using System.Security.Principal; -using System.Threading; -using System.Threading.Tasks; -using MessagePack; -using Serilog; -using Serilog.Core; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Ipc; - -/// -/// Driver.Historian.Wonderware-007 regression. The two other rejection paths -/// (shared-secret-mismatch and major-version-mismatch) both write a -/// with Accepted=false before disconnecting; the caller-SID-mismatch path used to -/// just disconnect abruptly, leaving the client to time out instead of learning why. -/// The fix sends a symmetric caller-sid-mismatch ack before disconnecting. -/// -/// The test uses the internal test-seam constructor so the verifier rejects without -/// needing to actually relax the pipe ACL (which would block the test client itself). -/// -public sealed class PipeServerSidRejectTests -{ - private static readonly ILogger Quiet = Logger.None; - - /// Verifies that a caller SID mismatch sends HelloAck with reject reason before disconnect. - [Fact] - public async Task Caller_SID_mismatch_sends_HelloAck_with_reject_reason_before_disconnect() - { - // The pipe ACL must allow the current process to connect — so wire up the pipe - // with the current user's SID. Then have the verifier seam simulate the SID - // mismatch by returning false. This isolates the "what does the server do on a - // rejected caller" question from the (separate) "is the ACL correct" question. - var current = WindowsIdentity.GetCurrent().User - ?? throw new InvalidOperationException("WindowsIdentity.GetCurrent().User was null — cannot run test"); - - var pipeName = $"otopcua-hist-sidreject-test-{Guid.NewGuid():N}"; - - PipeServer.CallerVerifier rejecting = (NamedPipeServerStream _, SecurityIdentifier _, out string reason) => - { - reason = "synthetic-mismatch"; - return false; - }; - using var server = new PipeServer(pipeName, current, "secret", Quiet, rejecting); - - var serverTask = Task.Run(() => server.RunOneConnectionAsync(new NoopHandler(), CancellationToken.None)); - - using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous); - await client.ConnectAsync(5_000); - - using var writer = new FrameWriter(client, leaveOpen: true); - using var reader = new FrameReader(client, leaveOpen: true); - - var hello = new Hello { ProtocolMajor = Hello.CurrentMajor, PeerName = "test", SharedSecret = "secret" }; - await writer.WriteAsync(MessageKind.Hello, hello, CancellationToken.None); - - // Read the rejecting HelloAck the server is expected to send before disconnecting. - var frame = await reader.ReadFrameAsync(CancellationToken.None); - frame.ShouldNotBeNull("server must send a HelloAck on caller-SID rejection, not just disconnect"); - frame!.Value.Kind.ShouldBe(MessageKind.HelloAck); - - var ack = MessagePackSerializer.Deserialize(frame.Value.Body); - ack.Accepted.ShouldBeFalse(); - ack.RejectReason.ShouldNotBeNullOrEmpty(); - ack.RejectReason!.ShouldContain("caller-sid-mismatch", - Case.Insensitive, - "reject reason must match the documented caller-sid-mismatch tag so clients can diagnose"); - - await serverTask; - } - - /// Handler that asserts it is never called — the connection must be rejected at Hello. - private sealed class NoopHandler : IFrameHandler - { - /// Throws if called, as the connection should be rejected before reaching this handler. - /// The message kind (unused). - /// The message body (unused). - /// The frame writer (unused). - /// Cancellation token (unused). - /// Never returns; always throws. - public Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct) - { - throw new InvalidOperationException( - $"Handler must not be reached on a rejected caller; got frame {kind}"); - } - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/HistorianWonderwareDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/HistorianWonderwareDriverPageFormSerializationTests.cs index 365a3e6f..d0c31303 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/HistorianWonderwareDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/HistorianWonderwareDriverPageFormSerializationTests.cs @@ -19,7 +19,8 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests public void RoundTrip_PreservesKnownFields() { var original = new WonderwareHistorianClientOptions( - PipeName: "otopcua-historian-prod", + Host: "historian-prod.zb.local", + Port: 32569, SharedSecret: "t0ps3cr3t", PeerName: "OtOpcUa-Primary", ConnectTimeout: TimeSpan.FromSeconds(20), @@ -32,7 +33,8 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests var back = JsonSerializer.Deserialize(json, _opts); back.ShouldNotBeNull(); - back.PipeName.ShouldBe("otopcua-historian-prod"); + back.Host.ShouldBe("historian-prod.zb.local"); + back.Port.ShouldBe(32569); back.SharedSecret.ShouldBe("t0ps3cr3t"); back.PeerName.ShouldBe("OtOpcUa-Primary"); back.ConnectTimeout.ShouldBe(TimeSpan.FromSeconds(20)); @@ -46,7 +48,8 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests public void RoundTrip_NullTimeouts_UsesDefaults() { var original = new WonderwareHistorianClientOptions( - PipeName: "otopcua-historian", + Host: "localhost", + Port: 32569, SharedSecret: "secret"); var json = JsonSerializer.Serialize(original, _opts); @@ -65,7 +68,8 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests var jsonWithExtra = """ { "unknownField": "old-value", - "pipeName": "otopcua-historian", + "host": "historian.zb.local", + "port": 32569, "sharedSecret": "s3cr3t", "probeTimeoutSeconds": 20 } @@ -79,7 +83,8 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests var back = JsonSerializer.Deserialize(jsonWithExtra, optsWithSkip); back.ShouldNotBeNull(); back.ProbeTimeoutSeconds.ShouldBe(20); - back.PipeName.ShouldBe("otopcua-historian"); + back.Host.ShouldBe("historian.zb.local"); + back.Port.ShouldBe(32569); } [Fact] @@ -88,7 +93,8 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests // Construct a record with non-default values for every property and verify // that WonderwareHistorianClientFormModel.FromRecord → ToRecord is lossless. var original = new WonderwareHistorianClientOptions( - PipeName: "otopcua-historian-prod", + Host: "historian-prod.zb.local", + Port: 32570, SharedSecret: "sup3rs3cr3t", PeerName: "OtOpcUa-Redundant", ConnectTimeout: TimeSpan.FromSeconds(18), @@ -100,7 +106,8 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests var form = HistorianWonderwareDriverPage.WonderwareHistorianClientFormModel.FromRecord(original); var result = form.ToRecord(); - result.PipeName.ShouldBe("otopcua-historian-prod"); + result.Host.ShouldBe("historian-prod.zb.local"); + result.Port.ShouldBe(32570); result.SharedSecret.ShouldBe("sup3rs3cr3t"); result.PeerName.ShouldBe("OtOpcUa-Redundant"); result.ConnectTimeout.ShouldBe(TimeSpan.FromSeconds(18));