refactor(historian): remove named-pipe transport

This commit is contained in:
Joseph Doherty
2026-06-12 11:51:53 -04:00
parent 6104eaba60
commit 72f32045a4
16 changed files with 84 additions and 819 deletions
@@ -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
};
@@ -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();
}
@@ -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,
};
@@ -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");
}
}
@@ -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}");
}
}
}