refactor(historian): remove named-pipe transport
This commit is contained in:
+5
-9
@@ -15,13 +15,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client;
|
|||||||
/// history router are expected to layer their own backoff on top.
|
/// history router are expected to layer their own backoff on top.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="PipeName">Named-pipe name the sidecar listens on (matches the sidecar's <c>OTOPCUA_HISTORIAN_PIPE</c>).</param>
|
/// <param name="Host">Sidecar TCP host (DNS name or IP) the client dials.</param>
|
||||||
|
/// <param name="Port">Sidecar TCP port (matches the sidecar's <c>OTOPCUA_HISTORIAN_TCP_PORT</c>).</param>
|
||||||
/// <param name="SharedSecret">Per-process shared secret the sidecar will verify in the Hello frame.</param>
|
/// <param name="SharedSecret">Per-process shared secret the sidecar will verify in the Hello frame.</param>
|
||||||
/// <param name="PeerName">Diagnostic peer identifier sent in Hello — typically the OtOpcUa instance id.</param>
|
/// <param name="PeerName">Diagnostic peer identifier sent in Hello — typically the OtOpcUa instance id.</param>
|
||||||
/// <param name="ConnectTimeout">Cap on the named-pipe connect + Hello round trip on each (re)connect.</param>
|
/// <param name="ConnectTimeout">Cap on the TCP connect + Hello round trip on each (re)connect.</param>
|
||||||
/// <param name="CallTimeout">Cap on a single read/write call once connected.</param>
|
/// <param name="CallTimeout">Cap on a single read/write call once connected.</param>
|
||||||
public sealed record WonderwareHistorianClientOptions(
|
public sealed record WonderwareHistorianClientOptions(
|
||||||
string PipeName,
|
string Host,
|
||||||
|
int Port,
|
||||||
string SharedSecret,
|
string SharedSecret,
|
||||||
string PeerName = "OtOpcUa",
|
string PeerName = "OtOpcUa",
|
||||||
TimeSpan? ConnectTimeout = null,
|
TimeSpan? ConnectTimeout = null,
|
||||||
@@ -41,12 +43,6 @@ public sealed record WonderwareHistorianClientOptions(
|
|||||||
[Range(1, 60)]
|
[Range(1, 60)]
|
||||||
public int ProbeTimeoutSeconds { get; init; } = 15;
|
public int ProbeTimeoutSeconds { get; init; } = 15;
|
||||||
|
|
||||||
/// <summary>Sidecar TCP host (DNS name or IP). Required for the TCP transport.</summary>
|
|
||||||
public string? Host { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Sidecar TCP port (matches the sidecar's OTOPCUA_HISTORIAN_TCP_PORT).</summary>
|
|
||||||
public int Port { get; init; }
|
|
||||||
|
|
||||||
/// <summary>When true, the client wraps the TCP stream in TLS before the Hello handshake.</summary>
|
/// <summary>When true, the client wraps the TCP stream in TLS before the Hello handshake.</summary>
|
||||||
public bool UseTls { get; init; }
|
public bool UseTls { get; init; }
|
||||||
|
|
||||||
|
|||||||
+2
-19
@@ -1,4 +1,3 @@
|
|||||||
using System.IO.Pipes;
|
|
||||||
using System.Net.Security;
|
using System.Net.Security;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
using System.Security.Authentication;
|
using System.Security.Authentication;
|
||||||
@@ -31,22 +30,6 @@ internal sealed class FrameChannel : IAsyncDisposable
|
|||||||
private FrameWriter? _writer;
|
private FrameWriter? _writer;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Default factory: connects to a real <see cref="NamedPipeClientStream"/> by name.
|
|
||||||
/// </summary>
|
|
||||||
public static Func<WonderwareHistorianClientOptions, CancellationToken, Task<Stream>> 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;
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Default TCP factory: connects to the sidecar over TCP, optionally wrapping the stream
|
/// 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 +
|
/// 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);
|
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
connectCts.CancelAfter(opts.EffectiveConnectTimeout);
|
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
|
catch
|
||||||
{
|
{
|
||||||
@@ -85,7 +68,7 @@ internal sealed class FrameChannel : IAsyncDisposable
|
|||||||
});
|
});
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ssl.AuthenticateAsClientAsync(opts.Host!).ConfigureAwait(false);
|
await ssl.AuthenticateAsClientAsync(opts.Host).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
|||||||
+6
-6
@@ -527,12 +527,12 @@ public sealed class WonderwareHistorianClient : IHistorianDataSource, IAlarmHist
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Synchronous dispose required by <see cref="IDisposable"/> on
|
/// Synchronous dispose required by <see cref="IDisposable"/> on
|
||||||
/// <see cref="IHistorianDataSource"/>. The underlying channel's async cleanup runs
|
/// <see cref="IHistorianDataSource"/>. The underlying channel's async cleanup runs the
|
||||||
/// <see cref="System.IO.Pipes.NamedPipeClientStream"/> teardown, which can block briefly
|
/// TCP socket teardown, which can block briefly on OS handle release — strictly speaking
|
||||||
/// on OS handle release — strictly speaking it is not non-blocking — but the
|
/// it is not non-blocking — but the <c>GetAwaiter()/GetResult()</c> bridge is
|
||||||
/// <c>GetAwaiter()/GetResult()</c> bridge is deadlock-safe because the cleanup never
|
/// deadlock-safe because the cleanup never awaits a captured
|
||||||
/// awaits a captured <see cref="System.Threading.SynchronizationContext"/> nor takes any
|
/// <see cref="System.Threading.SynchronizationContext"/> nor takes any lock that the
|
||||||
/// lock that the caller could hold. (Finding 010.)
|
/// caller could hold. (Finding 010.)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Dispose() => _channel.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
public void Dispose() => _channel.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strategy for handling each post-Hello frame the sidecar's <see cref="TcpFrameServer"/>
|
||||||
|
/// reads. Implementations deserialize the body per the <see cref="MessageKind"/>, dispatch
|
||||||
|
/// to the historian, and write the corresponding reply through the supplied
|
||||||
|
/// <see cref="FrameWriter"/>.
|
||||||
|
/// </summary>
|
||||||
|
public interface IFrameHandler
|
||||||
|
{
|
||||||
|
/// <summary>Handles a frame from the sidecar frame server.</summary>
|
||||||
|
/// <param name="kind">The type of message being handled.</param>
|
||||||
|
/// <param name="body">The serialized message body.</param>
|
||||||
|
/// <param name="writer">The frame writer to send responses.</param>
|
||||||
|
/// <param name="ct">Cancellation token for the operation.</param>
|
||||||
|
Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds a strict <see cref="PipeSecurity"/> for the historian sidecar pipe — only the
|
|
||||||
/// configured server-principal SID gets <c>ReadWrite | Synchronize</c>, 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.
|
|
||||||
/// </summary>
|
|
||||||
public static class PipeAcl
|
|
||||||
{
|
|
||||||
/// <summary>Creates a strict PipeSecurity for the historian sidecar pipe.</summary>
|
|
||||||
/// <param name="allowedSid">The security identifier that should have read-write access to the pipe.</param>
|
|
||||||
/// <returns>A configured PipeSecurity object with strict access control.</returns>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Accepts one client connection at a time on a named pipe with the strict ACL from
|
|
||||||
/// <see cref="PipeAcl"/>. 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.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Pluggable caller-verification seam. Default implementation calls
|
|
||||||
/// <see cref="VerifyCaller"/>; tests can substitute one that ignores the pipe ACL
|
|
||||||
/// to exercise the rejection paths.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="pipe">The named pipe server stream to verify.</param>
|
|
||||||
/// <param name="allowedSid">The allowed security identifier.</param>
|
|
||||||
/// <param name="reason">The rejection reason if verification fails.</param>
|
|
||||||
internal delegate bool CallerVerifier(NamedPipeServerStream pipe, SecurityIdentifier allowedSid, out string reason);
|
|
||||||
|
|
||||||
/// <summary>Initializes a new instance of the <see cref="PipeServer"/> class.</summary>
|
|
||||||
/// <param name="pipeName">The name of the named pipe.</param>
|
|
||||||
/// <param name="allowedSid">The security identifier allowed to connect.</param>
|
|
||||||
/// <param name="sharedSecret">The shared secret for client authentication.</param>
|
|
||||||
/// <param name="logger">The logger for diagnostic messages.</param>
|
|
||||||
public PipeServer(string pipeName, SecurityIdentifier allowedSid, string sharedSecret, ILogger logger)
|
|
||||||
: this(pipeName, allowedSid, sharedSecret, logger, DefaultVerifier) { }
|
|
||||||
|
|
||||||
/// <summary>Initializes a new instance of the <see cref="PipeServer"/> class with a custom verifier.</summary>
|
|
||||||
/// <param name="pipeName">The name of the named pipe.</param>
|
|
||||||
/// <param name="allowedSid">The security identifier allowed to connect.</param>
|
|
||||||
/// <param name="sharedSecret">The shared secret for client authentication.</param>
|
|
||||||
/// <param name="logger">The logger for diagnostic messages.</param>
|
|
||||||
/// <param name="verifier">The caller verification delegate.</param>
|
|
||||||
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);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Accepts one connection, performs Hello handshake, then dispatches frames to
|
|
||||||
/// <paramref name="handler"/> until EOF or cancel. Returns when the client disconnects.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="handler">The frame handler to process frames.</param>
|
|
||||||
/// <param name="ct">Cancellation token for the operation.</param>
|
|
||||||
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<Hello>(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),
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Maximum consecutive failures before the server gives up and lets the process exit
|
|
||||||
/// so the supervisor (NSSM / SCM) can restart the sidecar cleanly.
|
|
||||||
/// </summary>
|
|
||||||
private const int MaxConsecutiveFailures = 20;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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 <see cref="MaxConsecutiveFailures"/> consecutive failures occur the method
|
|
||||||
/// throws so the supervisor can restart the sidecar.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="handler">The frame handler to process frames.</param>
|
|
||||||
/// <param name="ct">Cancellation token for the operation.</param>
|
|
||||||
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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Disposes the pipe server and cancels any pending operations.</summary>
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
_cts.Cancel();
|
|
||||||
_current?.Dispose();
|
|
||||||
_cts.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Strategy for handling each post-Hello frame the pipe server reads. Implementations
|
|
||||||
/// deserialize the body per the <see cref="MessageKind"/>, dispatch to the historian, and
|
|
||||||
/// write the corresponding reply through the supplied <see cref="FrameWriter"/>.
|
|
||||||
/// </summary>
|
|
||||||
public interface IFrameHandler
|
|
||||||
{
|
|
||||||
/// <summary>Handles a frame from the pipe server.</summary>
|
|
||||||
/// <param name="kind">The type of message being handled.</param>
|
|
||||||
/// <param name="body">The serialized message body.</param>
|
|
||||||
/// <param name="writer">The frame writer to send responses.</param>
|
|
||||||
/// <param name="ct">Cancellation token for the operation.</param>
|
|
||||||
Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct);
|
|
||||||
}
|
|
||||||
@@ -14,8 +14,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Accepts one TCP client at a time, optionally over TLS, verifies the shared-secret
|
/// Accepts one TCP client at a time, optionally over TLS, verifies the shared-secret
|
||||||
/// Hello, then dispatches frames to <see cref="IFrameHandler"/>. The TCP replacement for
|
/// Hello, then dispatches frames to <see cref="IFrameHandler"/>. Authentication is the
|
||||||
/// <c>PipeServer</c>; the Windows-SID ACL is replaced by TLS + the shared secret.
|
/// shared secret carried in the Hello frame, optionally over a TLS-protected channel.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class TcpFrameServer : IDisposable
|
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 =
|
private static readonly TimeSpan[] BackoffSteps =
|
||||||
{
|
{
|
||||||
TimeSpan.FromMilliseconds(250), TimeSpan.FromMilliseconds(500), TimeSpan.FromSeconds(1),
|
TimeSpan.FromMilliseconds(250), TimeSpan.FromMilliseconds(500), TimeSpan.FromSeconds(1),
|
||||||
|
|||||||
+17
-9
@@ -66,11 +66,16 @@ else
|
|||||||
<div class="panel-head">Connection</div>
|
<div class="panel-head">Connection</div>
|
||||||
<div style="padding:1rem">
|
<div style="padding:1rem">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-5">
|
<div class="col-md-3">
|
||||||
<label class="form-label">Named pipe name</label>
|
<label class="form-label">Sidecar host</label>
|
||||||
<InputText @bind-Value="_form.Historian.PipeName" class="form-control form-control-sm mono"
|
<InputText @bind-Value="_form.Historian.Host" class="form-control form-control-sm mono"
|
||||||
placeholder="otopcua-historian" />
|
placeholder="localhost" />
|
||||||
<div class="form-text">Must match the sidecar's <code>OTOPCUA_HISTORIAN_PIPE</code> environment variable.</div>
|
<div class="form-text">DNS name or IP the historian sidecar's TCP listener is reachable at.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Sidecar port</label>
|
||||||
|
<InputNumber @bind-Value="_form.Historian.Port" class="form-control form-control-sm mono" />
|
||||||
|
<div class="form-text">Must match the sidecar's <code>OTOPCUA_HISTORIAN_TCP_PORT</code>.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label">Shared secret</label>
|
<label class="form-label">Shared secret</label>
|
||||||
@@ -209,7 +214,7 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static WonderwareHistorianClientOptions CreateDefaultOptions() =>
|
private static WonderwareHistorianClientOptions CreateDefaultOptions() =>
|
||||||
new(PipeName: "otopcua-historian", SharedSecret: "");
|
new(Host: "localhost", Port: 32569, SharedSecret: "");
|
||||||
|
|
||||||
private async Task SubmitAsync()
|
private async Task SubmitAsync()
|
||||||
{
|
{
|
||||||
@@ -309,7 +314,8 @@ else
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class WonderwareHistorianClientFormModel
|
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 SharedSecret { get; set; } = "";
|
||||||
public string PeerName { get; set; } = "OtOpcUa";
|
public string PeerName { get; set; } = "OtOpcUa";
|
||||||
public int? ConnectTimeoutSeconds { get; set; }
|
public int? ConnectTimeoutSeconds { get; set; }
|
||||||
@@ -318,7 +324,8 @@ else
|
|||||||
|
|
||||||
public static WonderwareHistorianClientFormModel FromRecord(WonderwareHistorianClientOptions r) => new()
|
public static WonderwareHistorianClientFormModel FromRecord(WonderwareHistorianClientOptions r) => new()
|
||||||
{
|
{
|
||||||
PipeName = r.PipeName,
|
Host = r.Host,
|
||||||
|
Port = r.Port,
|
||||||
SharedSecret = r.SharedSecret,
|
SharedSecret = r.SharedSecret,
|
||||||
PeerName = r.PeerName,
|
PeerName = r.PeerName,
|
||||||
ConnectTimeoutSeconds = r.ConnectTimeout.HasValue ? (int)r.ConnectTimeout.Value.TotalSeconds : null,
|
ConnectTimeoutSeconds = r.ConnectTimeout.HasValue ? (int)r.ConnectTimeout.Value.TotalSeconds : null,
|
||||||
@@ -327,7 +334,8 @@ else
|
|||||||
};
|
};
|
||||||
|
|
||||||
public WonderwareHistorianClientOptions ToRecord() => new(
|
public WonderwareHistorianClientOptions ToRecord() => new(
|
||||||
PipeName: PipeName,
|
Host: Host,
|
||||||
|
Port: Port,
|
||||||
SharedSecret: SharedSecret,
|
SharedSecret: SharedSecret,
|
||||||
PeerName: PeerName,
|
PeerName: PeerName,
|
||||||
ConnectTimeout: ConnectTimeoutSeconds.HasValue ? TimeSpan.FromSeconds(ConnectTimeoutSeconds.Value) : null,
|
ConnectTimeout: ConnectTimeoutSeconds.HasValue ? TimeSpan.FromSeconds(ConnectTimeoutSeconds.Value) : null,
|
||||||
|
|||||||
@@ -88,15 +88,15 @@ if (hasDriver)
|
|||||||
|
|
||||||
// Config-gated durable alarm-historian sink. When the AlarmHistorian section is enabled this
|
// Config-gated durable alarm-historian sink. When the AlarmHistorian section is enabled this
|
||||||
// overrides the NullAlarmHistorianSink default from AddOtOpcUaRuntime (last registration wins)
|
// 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 —
|
// 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.
|
// Runtime owns the gating + Sqlite construction, the Host supplies the concrete downstream.
|
||||||
builder.Services.AddAlarmHistorian(
|
builder.Services.AddAlarmHistorian(
|
||||||
builder.Configuration,
|
builder.Configuration,
|
||||||
(opts, sp) => new WonderwareHistorianClient(
|
(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<ILogger<WonderwareHistorianClient>>()));
|
sp.GetService<ILogger<WonderwareHistorianClient>>()));
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
|||||||
/// Binds the <c>AlarmHistorian</c> configuration section that gates the durable
|
/// Binds the <c>AlarmHistorian</c> configuration section that gates the durable
|
||||||
/// store-and-forward alarm sink. When <see cref="Enabled"/> is <c>true</c>,
|
/// store-and-forward alarm sink. When <see cref="Enabled"/> is <c>true</c>,
|
||||||
/// <c>AddAlarmHistorian</c> registers a <c>SqliteStoreAndForwardSink</c> (draining to the
|
/// <c>AddAlarmHistorian</c> registers a <c>SqliteStoreAndForwardSink</c> (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
|
||||||
/// <c>NullAlarmHistorianSink</c> default; otherwise the Null default survives.
|
/// <c>NullAlarmHistorianSink</c> default; otherwise the Null default survives.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AlarmHistorianOptions
|
public sealed class AlarmHistorianOptions
|
||||||
@@ -25,9 +25,6 @@ public sealed class AlarmHistorianOptions
|
|||||||
/// <summary>Filesystem path to the local SQLite store-and-forward queue database.</summary>
|
/// <summary>Filesystem path to the local SQLite store-and-forward queue database.</summary>
|
||||||
public string DatabasePath { get; init; } = "alarm-historian.db";
|
public string DatabasePath { get; init; } = "alarm-historian.db";
|
||||||
|
|
||||||
/// <summary>Named-pipe name the Wonderware historian sidecar listens on.</summary>
|
|
||||||
public string PipeName { get; init; } = "OtOpcUaHistorian";
|
|
||||||
|
|
||||||
/// <summary>TCP hostname or IP address the Wonderware historian sidecar listens on.</summary>
|
/// <summary>TCP hostname or IP address the Wonderware historian sidecar listens on.</summary>
|
||||||
public string Host { get; init; } = "localhost";
|
public string Host { get; init; } = "localhost";
|
||||||
|
|
||||||
|
|||||||
+3
-9
@@ -55,10 +55,8 @@ public sealed class TcpConnectFactoryTests
|
|||||||
await Task.Delay(TimeSpan.FromMilliseconds(200), cts.Token);
|
await Task.Delay(TimeSpan.FromMilliseconds(200), cts.Token);
|
||||||
}, 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,
|
UseTls = false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -94,10 +92,8 @@ public sealed class TcpConnectFactoryTests
|
|||||||
ssl.Dispose();
|
ssl.Dispose();
|
||||||
}, 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 = true,
|
UseTls = true,
|
||||||
ServerCertThumbprint = cert.GetCertHashString(),
|
ServerCertThumbprint = cert.GetCertHashString(),
|
||||||
};
|
};
|
||||||
@@ -137,10 +133,8 @@ public sealed class TcpConnectFactoryTests
|
|||||||
}
|
}
|
||||||
}, 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 = true,
|
UseTls = true,
|
||||||
ServerCertThumbprint = "00112233445566778899AABBCCDDEEFF00112233", // bogus
|
ServerCertThumbprint = "00112233445566778899AABBCCDDEEFF00112233", // bogus
|
||||||
};
|
};
|
||||||
|
|||||||
+4
-6
@@ -11,10 +11,8 @@ public sealed class WonderwareHistorianClientOptionsTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void TcpTlsFields_AreStoredCorrectly_WhenExplicitlySet()
|
public void TcpTlsFields_AreStoredCorrectly_WhenExplicitlySet()
|
||||||
{
|
{
|
||||||
var opts = new WonderwareHistorianClientOptions("pipe", "secret")
|
var opts = new WonderwareHistorianClientOptions("h", 32569, "secret")
|
||||||
{
|
{
|
||||||
Host = "h",
|
|
||||||
Port = 32569,
|
|
||||||
UseTls = true,
|
UseTls = true,
|
||||||
ServerCertThumbprint = "AB"
|
ServerCertThumbprint = "AB"
|
||||||
};
|
};
|
||||||
@@ -28,10 +26,10 @@ public sealed class WonderwareHistorianClientOptionsTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void TcpTlsFields_HaveCorrectDefaults_WhenNotSet()
|
public void TcpTlsFields_HaveCorrectDefaults_WhenNotSet()
|
||||||
{
|
{
|
||||||
var opts = new WonderwareHistorianClientOptions("pipe", "secret");
|
var opts = new WonderwareHistorianClientOptions("host", 32569, "secret");
|
||||||
|
|
||||||
opts.Host.ShouldBeNull();
|
opts.Host.ShouldBe("host");
|
||||||
opts.Port.ShouldBe(0);
|
opts.Port.ShouldBe(32569);
|
||||||
opts.UseTls.ShouldBeFalse();
|
opts.UseTls.ShouldBeFalse();
|
||||||
opts.ServerCertThumbprint.ShouldBeNull();
|
opts.ServerCertThumbprint.ShouldBeNull();
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-9
@@ -22,14 +22,13 @@ public sealed class WonderwareHistorianClientTests
|
|||||||
private const string Secret = "test-secret-123";
|
private const string Secret = "test-secret-123";
|
||||||
|
|
||||||
private static WonderwareHistorianClientOptions OptsFor(FakeSidecarServer server) => new(
|
private static WonderwareHistorianClientOptions OptsFor(FakeSidecarServer server) => new(
|
||||||
PipeName: "",
|
Host: "127.0.0.1",
|
||||||
|
Port: server.BoundPort,
|
||||||
SharedSecret: Secret,
|
SharedSecret: Secret,
|
||||||
PeerName: "test",
|
PeerName: "test",
|
||||||
ConnectTimeout: TimeSpan.FromSeconds(2),
|
ConnectTimeout: TimeSpan.FromSeconds(2),
|
||||||
CallTimeout: TimeSpan.FromSeconds(2))
|
CallTimeout: TimeSpan.FromSeconds(2))
|
||||||
{
|
{
|
||||||
Host = "127.0.0.1",
|
|
||||||
Port = server.BoundPort,
|
|
||||||
UseTls = false,
|
UseTls = false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -445,14 +444,13 @@ public sealed class WonderwareHistorianClientTests
|
|||||||
await server.StartAsync();
|
await server.StartAsync();
|
||||||
|
|
||||||
var opts = new WonderwareHistorianClientOptions(
|
var opts = new WonderwareHistorianClientOptions(
|
||||||
PipeName: "",
|
Host: "127.0.0.1",
|
||||||
|
Port: server.BoundPort,
|
||||||
SharedSecret: Secret,
|
SharedSecret: Secret,
|
||||||
PeerName: "test",
|
PeerName: "test",
|
||||||
ConnectTimeout: TimeSpan.FromSeconds(2),
|
ConnectTimeout: TimeSpan.FromSeconds(2),
|
||||||
CallTimeout: TimeSpan.FromMilliseconds(500)) // short timeout for test speed
|
CallTimeout: TimeSpan.FromMilliseconds(500)) // short timeout for test speed
|
||||||
{
|
{
|
||||||
Host = "127.0.0.1",
|
|
||||||
Port = server.BoundPort,
|
|
||||||
UseTls = false,
|
UseTls = false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -661,13 +659,12 @@ public sealed class WonderwareHistorianClientTests
|
|||||||
|
|
||||||
// 3. Construct the client via the PUBLIC ctor (no ForTests factory).
|
// 3. Construct the client via the PUBLIC ctor (no ForTests factory).
|
||||||
var opts = new WonderwareHistorianClientOptions(
|
var opts = new WonderwareHistorianClientOptions(
|
||||||
PipeName: "ignored-pipe",
|
Host: "127.0.0.1",
|
||||||
|
Port: boundPort,
|
||||||
SharedSecret: Secret,
|
SharedSecret: Secret,
|
||||||
ConnectTimeout: TimeSpan.FromSeconds(5),
|
ConnectTimeout: TimeSpan.FromSeconds(5),
|
||||||
CallTimeout: TimeSpan.FromSeconds(5))
|
CallTimeout: TimeSpan.FromSeconds(5))
|
||||||
{
|
{
|
||||||
Host = "127.0.0.1",
|
|
||||||
Port = boundPort,
|
|
||||||
UseTls = false,
|
UseTls = false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
-348
@@ -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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Round-trip tests for the sidecar pipe contract added in PR 3.3. Each scenario serializes
|
|
||||||
/// a Request through the wire framing, dispatches via <see cref="HistorianFrameHandler"/>
|
|
||||||
/// 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
|
|
||||||
/// <see cref="MemoryStream"/> pair so tests stay fast and platform-independent.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class PipeRoundTripTests
|
|
||||||
{
|
|
||||||
private static readonly ILogger Quiet = Logger.None;
|
|
||||||
|
|
||||||
private sealed class FakeHistorian : IHistorianDataSource
|
|
||||||
{
|
|
||||||
/// <summary>Gets or sets the raw samples to return from reads.</summary>
|
|
||||||
public List<HistorianSample> RawSamples { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>Gets or sets the aggregate samples to return from reads.</summary>
|
|
||||||
public List<HistorianAggregateSample> AggregateSamples { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>Gets or sets the at-time samples to return from reads.</summary>
|
|
||||||
public List<HistorianSample> AtTimeSamples { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>Gets or sets the events to return from reads.</summary>
|
|
||||||
public List<BackendHistorianEventDto> Events { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>Gets or sets an exception to throw from read operations.</summary>
|
|
||||||
public Exception? ThrowFromRead { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads raw samples from the fake historian or throws if configured.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="tagName">The tag name.</param>
|
|
||||||
/// <param name="startTime">The start time.</param>
|
|
||||||
/// <param name="endTime">The end time.</param>
|
|
||||||
/// <param name="maxValues">The maximum number of values to return.</param>
|
|
||||||
/// <param name="ct">Cancellation token.</param>
|
|
||||||
/// <returns>The raw samples.</returns>
|
|
||||||
public Task<List<HistorianSample>> ReadRawAsync(string tagName, DateTime startTime, DateTime endTime, int maxValues, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
if (ThrowFromRead is not null) throw ThrowFromRead;
|
|
||||||
return Task.FromResult(RawSamples);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads aggregate samples from the fake historian.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="tagName">The tag name.</param>
|
|
||||||
/// <param name="startTime">The start time.</param>
|
|
||||||
/// <param name="endTime">The end time.</param>
|
|
||||||
/// <param name="intervalMs">The interval in milliseconds.</param>
|
|
||||||
/// <param name="aggregateColumn">The aggregate column name.</param>
|
|
||||||
/// <param name="ct">Cancellation token.</param>
|
|
||||||
/// <returns>The aggregate samples.</returns>
|
|
||||||
public Task<List<HistorianAggregateSample>> ReadAggregateAsync(string tagName, DateTime startTime, DateTime endTime, double intervalMs, string aggregateColumn, CancellationToken ct = default)
|
|
||||||
=> Task.FromResult(AggregateSamples);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads at-time samples from the fake historian.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="tagName">The tag name.</param>
|
|
||||||
/// <param name="timestamps">The timestamps to read at.</param>
|
|
||||||
/// <param name="ct">Cancellation token.</param>
|
|
||||||
/// <returns>The at-time samples.</returns>
|
|
||||||
public Task<List<HistorianSample>> ReadAtTimeAsync(string tagName, DateTime[] timestamps, CancellationToken ct = default)
|
|
||||||
=> Task.FromResult(AtTimeSamples);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads events from the fake historian.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="sourceName">The event source name.</param>
|
|
||||||
/// <param name="startTime">The start time.</param>
|
|
||||||
/// <param name="endTime">The end time.</param>
|
|
||||||
/// <param name="maxEvents">The maximum number of events to return.</param>
|
|
||||||
/// <param name="ct">Cancellation token.</param>
|
|
||||||
/// <returns>The events.</returns>
|
|
||||||
public Task<List<BackendHistorianEventDto>> ReadEventsAsync(string? sourceName, DateTime startTime, DateTime endTime, int maxEvents, CancellationToken ct = default)
|
|
||||||
=> Task.FromResult(Events);
|
|
||||||
|
|
||||||
/// <summary>Gets a health snapshot of the fake historian.</summary>
|
|
||||||
/// <returns>A health snapshot.</returns>
|
|
||||||
public HistorianHealthSnapshot GetHealthSnapshot() => new();
|
|
||||||
|
|
||||||
/// <summary>Disposes the fake historian.</summary>
|
|
||||||
public void Dispose() { }
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class FakeAlarmWriter : IAlarmEventWriter
|
|
||||||
{
|
|
||||||
/// <summary>Gets the events received by this writer.</summary>
|
|
||||||
public List<AlarmHistorianEventDto> Received { get; } = new();
|
|
||||||
|
|
||||||
/// <summary>Gets or sets a delegate that decides whether each event should be marked as successfully written.</summary>
|
|
||||||
public Func<AlarmHistorianEventDto, bool> Decide { get; set; } = _ => true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Writes alarm events to the fake writer and returns per-event status based on the <see cref="Decide"/> delegate.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="events">The events to write.</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
/// <returns>An array of booleans indicating success for each event.</returns>
|
|
||||||
public Task<bool[]> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Drives one round trip: serialize <paramref name="request"/>, run the handler,
|
|
||||||
/// read the reply frame, deserialize it. Returns the reply.
|
|
||||||
/// </summary>
|
|
||||||
private static async Task<TReply> RoundTripAsync<TRequest, TReply>(
|
|
||||||
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<TReply>(frame.Value.Body);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that raw historian samples round-trip correctly through the frame handler.</summary>
|
|
||||||
[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<ReadRawRequest, ReadRawReply>(
|
|
||||||
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<double>(reply.Samples[0].ValueBytes!).ShouldBe(42.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that read failures are properly surfaced as error replies.</summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task ReadRaw_FailureSurfacesAsErrorReply()
|
|
||||||
{
|
|
||||||
var historian = new FakeHistorian { ThrowFromRead = new InvalidOperationException("boom") };
|
|
||||||
var handler = new HistorianFrameHandler(historian, Quiet);
|
|
||||||
var reply = await RoundTripAsync<ReadRawRequest, ReadRawReply>(
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that processed (aggregate) historian samples round-trip correctly through the frame handler.</summary>
|
|
||||||
[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<ReadProcessedRequest, ReadProcessedReply>(
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that at-time historian samples round-trip correctly through the frame handler.</summary>
|
|
||||||
[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<ReadAtTimeRequest, ReadAtTimeReply>(
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that historian events round-trip correctly through the frame handler.</summary>
|
|
||||||
[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<ReadEventsRequest, ReadEventsReply>(
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that alarm events are routed to the writer and per-event status is returned.</summary>
|
|
||||||
[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<WriteAlarmEventsRequest, WriteAlarmEventsReply>(
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that writing alarm events fails cleanly when no writer is configured.</summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task WriteAlarmEvents_FailsCleanly_WhenNoWriterConfigured()
|
|
||||||
{
|
|
||||||
var historian = new FakeHistorian();
|
|
||||||
var handler = new HistorianFrameHandler(historian, Quiet, alarmWriter: null);
|
|
||||||
|
|
||||||
var reply = await RoundTripAsync<WriteAlarmEventsRequest, WriteAlarmEventsReply>(
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that frame reader and writer preserve message kind and body through a round trip.</summary>
|
|
||||||
[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<Hello>(frame.Value.Body);
|
|
||||||
decoded.PeerName.ShouldBe("test-peer");
|
|
||||||
decoded.SharedSecret.ShouldBe("secret");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-90
@@ -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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Driver.Historian.Wonderware-007 regression. The two other rejection paths
|
|
||||||
/// (shared-secret-mismatch and major-version-mismatch) both write a <see cref="HelloAck"/>
|
|
||||||
/// with <c>Accepted=false</c> 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 <c>caller-sid-mismatch</c> 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).
|
|
||||||
/// </summary>
|
|
||||||
public sealed class PipeServerSidRejectTests
|
|
||||||
{
|
|
||||||
private static readonly ILogger Quiet = Logger.None;
|
|
||||||
|
|
||||||
/// <summary>Verifies that a caller SID mismatch sends HelloAck with reject reason before disconnect.</summary>
|
|
||||||
[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<HelloAck>(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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Handler that asserts it is never called — the connection must be rejected at Hello.</summary>
|
|
||||||
private sealed class NoopHandler : IFrameHandler
|
|
||||||
{
|
|
||||||
/// <summary>Throws if called, as the connection should be rejected before reaching this handler.</summary>
|
|
||||||
/// <param name="kind">The message kind (unused).</param>
|
|
||||||
/// <param name="body">The message body (unused).</param>
|
|
||||||
/// <param name="writer">The frame writer (unused).</param>
|
|
||||||
/// <param name="ct">Cancellation token (unused).</param>
|
|
||||||
/// <returns>Never returns; always throws.</returns>
|
|
||||||
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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+14
-7
@@ -19,7 +19,8 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests
|
|||||||
public void RoundTrip_PreservesKnownFields()
|
public void RoundTrip_PreservesKnownFields()
|
||||||
{
|
{
|
||||||
var original = new WonderwareHistorianClientOptions(
|
var original = new WonderwareHistorianClientOptions(
|
||||||
PipeName: "otopcua-historian-prod",
|
Host: "historian-prod.zb.local",
|
||||||
|
Port: 32569,
|
||||||
SharedSecret: "t0ps3cr3t",
|
SharedSecret: "t0ps3cr3t",
|
||||||
PeerName: "OtOpcUa-Primary",
|
PeerName: "OtOpcUa-Primary",
|
||||||
ConnectTimeout: TimeSpan.FromSeconds(20),
|
ConnectTimeout: TimeSpan.FromSeconds(20),
|
||||||
@@ -32,7 +33,8 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests
|
|||||||
var back = JsonSerializer.Deserialize<WonderwareHistorianClientOptions>(json, _opts);
|
var back = JsonSerializer.Deserialize<WonderwareHistorianClientOptions>(json, _opts);
|
||||||
|
|
||||||
back.ShouldNotBeNull();
|
back.ShouldNotBeNull();
|
||||||
back.PipeName.ShouldBe("otopcua-historian-prod");
|
back.Host.ShouldBe("historian-prod.zb.local");
|
||||||
|
back.Port.ShouldBe(32569);
|
||||||
back.SharedSecret.ShouldBe("t0ps3cr3t");
|
back.SharedSecret.ShouldBe("t0ps3cr3t");
|
||||||
back.PeerName.ShouldBe("OtOpcUa-Primary");
|
back.PeerName.ShouldBe("OtOpcUa-Primary");
|
||||||
back.ConnectTimeout.ShouldBe(TimeSpan.FromSeconds(20));
|
back.ConnectTimeout.ShouldBe(TimeSpan.FromSeconds(20));
|
||||||
@@ -46,7 +48,8 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests
|
|||||||
public void RoundTrip_NullTimeouts_UsesDefaults()
|
public void RoundTrip_NullTimeouts_UsesDefaults()
|
||||||
{
|
{
|
||||||
var original = new WonderwareHistorianClientOptions(
|
var original = new WonderwareHistorianClientOptions(
|
||||||
PipeName: "otopcua-historian",
|
Host: "localhost",
|
||||||
|
Port: 32569,
|
||||||
SharedSecret: "secret");
|
SharedSecret: "secret");
|
||||||
|
|
||||||
var json = JsonSerializer.Serialize(original, _opts);
|
var json = JsonSerializer.Serialize(original, _opts);
|
||||||
@@ -65,7 +68,8 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests
|
|||||||
var jsonWithExtra = """
|
var jsonWithExtra = """
|
||||||
{
|
{
|
||||||
"unknownField": "old-value",
|
"unknownField": "old-value",
|
||||||
"pipeName": "otopcua-historian",
|
"host": "historian.zb.local",
|
||||||
|
"port": 32569,
|
||||||
"sharedSecret": "s3cr3t",
|
"sharedSecret": "s3cr3t",
|
||||||
"probeTimeoutSeconds": 20
|
"probeTimeoutSeconds": 20
|
||||||
}
|
}
|
||||||
@@ -79,7 +83,8 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests
|
|||||||
var back = JsonSerializer.Deserialize<WonderwareHistorianClientOptions>(jsonWithExtra, optsWithSkip);
|
var back = JsonSerializer.Deserialize<WonderwareHistorianClientOptions>(jsonWithExtra, optsWithSkip);
|
||||||
back.ShouldNotBeNull();
|
back.ShouldNotBeNull();
|
||||||
back.ProbeTimeoutSeconds.ShouldBe(20);
|
back.ProbeTimeoutSeconds.ShouldBe(20);
|
||||||
back.PipeName.ShouldBe("otopcua-historian");
|
back.Host.ShouldBe("historian.zb.local");
|
||||||
|
back.Port.ShouldBe(32569);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -88,7 +93,8 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests
|
|||||||
// Construct a record with non-default values for every property and verify
|
// Construct a record with non-default values for every property and verify
|
||||||
// that WonderwareHistorianClientFormModel.FromRecord → ToRecord is lossless.
|
// that WonderwareHistorianClientFormModel.FromRecord → ToRecord is lossless.
|
||||||
var original = new WonderwareHistorianClientOptions(
|
var original = new WonderwareHistorianClientOptions(
|
||||||
PipeName: "otopcua-historian-prod",
|
Host: "historian-prod.zb.local",
|
||||||
|
Port: 32570,
|
||||||
SharedSecret: "sup3rs3cr3t",
|
SharedSecret: "sup3rs3cr3t",
|
||||||
PeerName: "OtOpcUa-Redundant",
|
PeerName: "OtOpcUa-Redundant",
|
||||||
ConnectTimeout: TimeSpan.FromSeconds(18),
|
ConnectTimeout: TimeSpan.FromSeconds(18),
|
||||||
@@ -100,7 +106,8 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests
|
|||||||
var form = HistorianWonderwareDriverPage.WonderwareHistorianClientFormModel.FromRecord(original);
|
var form = HistorianWonderwareDriverPage.WonderwareHistorianClientFormModel.FromRecord(original);
|
||||||
var result = form.ToRecord();
|
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.SharedSecret.ShouldBe("sup3rs3cr3t");
|
||||||
result.PeerName.ShouldBe("OtOpcUa-Redundant");
|
result.PeerName.ShouldBe("OtOpcUa-Redundant");
|
||||||
result.ConnectTimeout.ShouldBe(TimeSpan.FromSeconds(18));
|
result.ConnectTimeout.ShouldBe(TimeSpan.FromSeconds(18));
|
||||||
|
|||||||
Reference in New Issue
Block a user