using System.Net; using System.Net.Sockets; using MessagePack; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests; /// /// In-process fake of the Wonderware historian sidecar. Reuses the client-side framing /// code (which is byte-identical to the real sidecar) so the wire bytes round-trip /// correctly without requiring the .NET 4.8 sidecar binary at test time. Listens on a /// loopback and serves one connection at a time, mirroring the /// real sidecar's TcpFrameServer single-active-connection model. /// internal sealed class FakeSidecarServer : IAsyncDisposable { private readonly string _expectedSecret; private readonly TcpListener _listener; private readonly CancellationTokenSource _cts = new(); private Task? _loop; /// Gets or sets the handler for ReadRaw requests. public Func OnReadRaw { get; set; } = _ => new ReadRawReply { Success = true }; /// Gets or sets the handler for ReadProcessed requests. public Func OnReadProcessed { get; set; } = _ => new ReadProcessedReply { Success = true }; /// Gets or sets the handler for ReadAtTime requests. public Func OnReadAtTime { get; set; } = _ => new ReadAtTimeReply { Success = true }; /// Gets or sets the handler for ReadEvents requests. public Func OnReadEvents { get; set; } = _ => new ReadEventsReply { Success = true }; /// Gets or sets the handler for WriteAlarmEvents requests. public Func OnWriteAlarmEvents { get; set; } = req => new WriteAlarmEventsReply { Success = true, PerEventOk = Enumerable.Repeat(true, req.Events.Length).ToArray() }; /// Force-disconnect the next accepted client mid-call to exercise reconnect. public bool DisconnectAfterHandshake { get; set; } /// /// Drop the connection after the handshake but before replying to any non-Hello request. /// Armed for every connection until reset. Used to exercise the WriteBatchAsync catch /// path and the second-attempt-also-fails propagation path. /// public bool DisconnectBeforeReply { get; set; } /// /// Reply to the first non-Hello request with this kind instead of the expected kind, /// to exercise detection in ExchangeAsync. /// Reset to null after the first mis-routed reply. /// public MessageKind? ReplyWithWrongKind { get; set; } /// /// Stall indefinitely after receiving a request before sending any reply, so the client's /// call-timeout token fires. Used to test the CallTimeout path. /// public bool StallAfterRequest { get; set; } /// Initializes a new instance of FakeSidecarServer with the specified expected secret. /// The expected shared secret for handshake validation. public FakeSidecarServer(string expectedSecret) { _expectedSecret = expectedSecret; // Bind synchronously in the ctor so BoundPort is readable before StartAsync returns. _listener = new TcpListener(IPAddress.Loopback, 0); _listener.Start(); } /// Gets the loopback host the listener is bound to. public string Host => "127.0.0.1"; /// Gets the TCP port the listener actually bound (OS-assigned). public int BoundPort => ((IPEndPoint)_listener.LocalEndpoint).Port; /// Starts the fake sidecar server asynchronously. The listener is already bound (ctor). public Task StartAsync() { _loop = Task.Run(() => RunAsync(_cts.Token)); return Task.CompletedTask; } private async Task RunAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { TcpClient tcpClient; try { tcpClient = await _listener.AcceptTcpClientAsync(ct).ConfigureAwait(false); } catch (OperationCanceledException) { break; } catch (ObjectDisposedException) { break; } using (tcpClient) { try { tcpClient.NoDelay = true; var stream = tcpClient.GetStream(); using var reader = new FrameReader(stream, leaveOpen: true); using var writer = new FrameWriter(stream, leaveOpen: true); // Hello handshake. var first = await reader.ReadFrameAsync(ct).ConfigureAwait(false); if (first is null || first.Value.Kind != MessageKind.Hello) continue; var hello = MessagePackSerializer.Deserialize(first.Value.Body); if (!string.Equals(hello.SharedSecret, _expectedSecret, StringComparison.Ordinal)) { await writer.WriteAsync(MessageKind.HelloAck, new HelloAck { Accepted = false, RejectReason = "shared-secret-mismatch" }, ct); continue; } await writer.WriteAsync(MessageKind.HelloAck, new HelloAck { Accepted = true, HostName = "fake-sidecar" }, ct); if (DisconnectAfterHandshake) { DisconnectAfterHandshake = false; // arm once tcpClient.Close(); continue; } while (!ct.IsCancellationRequested) { var frame = await reader.ReadFrameAsync(ct).ConfigureAwait(false); if (frame is null) break; // Drop before sending any reply — lets the client fall into its catch / // retry path or propagate on second failure. if (DisconnectBeforeReply) { tcpClient.Close(); break; } // Stall indefinitely to let the client's call-timeout token fire. if (StallAfterRequest) { await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); break; } // Optionally send a deliberately wrong kind back to exercise // InvalidDataException detection in the client's ExchangeAsync. if (ReplyWithWrongKind.HasValue) { var wrongKind = ReplyWithWrongKind.Value; ReplyWithWrongKind = null; // arm once // Send an empty body with the wrong kind so the client can parse it. await writer.WriteAsync(wrongKind, new ReadRawReply { Success = false }, ct); continue; } switch (frame.Value.Kind) { case MessageKind.ReadRawRequest: { var req = MessagePackSerializer.Deserialize(frame.Value.Body); var reply = OnReadRaw(req); reply.CorrelationId = req.CorrelationId; await writer.WriteAsync(MessageKind.ReadRawReply, reply, ct); break; } case MessageKind.ReadProcessedRequest: { var req = MessagePackSerializer.Deserialize(frame.Value.Body); var reply = OnReadProcessed(req); reply.CorrelationId = req.CorrelationId; await writer.WriteAsync(MessageKind.ReadProcessedReply, reply, ct); break; } case MessageKind.ReadAtTimeRequest: { var req = MessagePackSerializer.Deserialize(frame.Value.Body); var reply = OnReadAtTime(req); reply.CorrelationId = req.CorrelationId; await writer.WriteAsync(MessageKind.ReadAtTimeReply, reply, ct); break; } case MessageKind.ReadEventsRequest: { var req = MessagePackSerializer.Deserialize(frame.Value.Body); var reply = OnReadEvents(req); reply.CorrelationId = req.CorrelationId; await writer.WriteAsync(MessageKind.ReadEventsReply, reply, ct); break; } case MessageKind.WriteAlarmEventsRequest: { var req = MessagePackSerializer.Deserialize(frame.Value.Body); var reply = OnWriteAlarmEvents(req); reply.CorrelationId = req.CorrelationId; await writer.WriteAsync(MessageKind.WriteAlarmEventsReply, reply, ct); break; } } } } catch (OperationCanceledException) { break; } catch (IOException) { /* peer dropped — accept next */ } } } } /// Releases all resources used by the fake sidecar server. public async ValueTask DisposeAsync() { _cts.Cancel(); try { _listener.Stop(); } catch { /* ignore */ } if (_loop is not null) { try { await _loop.ConfigureAwait(false); } catch { /* ignore shutdown errors */ } } _cts.Dispose(); } }