Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/IpcHandshakeIntegrationTests.cs
Joseph Doherty a7126ba953 Phase 2 — port MXAccess COM client to Galaxy.Host + MxAccessGalaxyBackend (3rd IGalaxyBackend) + live MXAccess smoke + Phase 2 exit-gate doc + adversarial review. The full Galaxy data-plane now flows through the v2 IPC topology end-to-end against live ArchestrA.MxAccess.dll, on this dev box, with 30/30 Host tests + 9/9 Proxy tests + 963/963 solution tests passing alongside the unchanged 494 v1 IntegrationTests baseline. Backend/MxAccess/Vtq is a focused port of v1's Vtq value-timestamp-quality DTO. Backend/MxAccess/IMxProxy abstracts LMXProxyServer (port of v1's IMxProxy with the same Register/Unregister/AddItem/RemoveItem/AdviseSupervisory/UnAdviseSupervisory/Write surface + OnDataChange + OnWriteComplete events); MxProxyAdapter is the concrete COM-backed implementation that does Marshal.ReleaseComObject-loop on Unregister, must be constructed on an STA thread. Backend/MxAccess/MxAccessClient is the focused port of v1's MxAccessClient partials — Connect/Disconnect/Read/Write/Subscribe/Unsubscribe through the new Sta/StaPump (the real Win32 GetMessage pump from the previous commit), ConcurrentDictionary handle tracking, OnDataChange event marshalling to per-tag callbacks, ReadAsync implemented as the canonical subscribe → first-OnDataChange → unsubscribe one-shot pattern. Galaxy.Host csproj flipped to x86 PlatformTarget + Prefer32Bit=true with the ArchestrA.MxAccess HintPath ..\..\lib\ArchestrA.MxAccess.dll reference (lib/ already contains the production DLL). Backend/MxAccessGalaxyBackend is the third IGalaxyBackend implementation (alongside StubGalaxyBackend and DbBackedGalaxyBackend): combines GalaxyRepository (Discover) with MxAccessClient (Read/Write/Subscribe), MessagePack-deserializes inbound write values, MessagePack-serializes outbound read values into ValueBytes, decodes ArrayDimension/SecurityClassification/category_id with the same v1 mapping. Program.cs selects between stub|db|mxaccess via OTOPCUA_GALAXY_BACKEND env var (default = mxaccess); OTOPCUA_GALAXY_ZB_CONN overrides the ZB connection string; OTOPCUA_GALAXY_CLIENT_NAME sets the Wonderware client identity; the StaPump and MxAccessClient lifecycles are tied to the server.RunAsync try/finally so a clean Ctrl+C tears down the COM proxy via Marshal.ReleaseComObject before the pump's WM_QUIT. Live MXAccess smoke tests (MxAccessLiveSmokeTests, net48 x86) — skipped when ZB unreachable or aaBootstrap not running, otherwise verify (1) MxAccessClient.ConnectAsync returns a positive LMXProxyServer handle on the StaPump, (2) MxAccessGalaxyBackend.OpenSession + Discover returns at least one gobject with attributes, (3) MxAccessGalaxyBackend.ReadValues against the first discovered attribute returns a response with the correct TagReference shape (value + quality vary by what's running, so we don't assert specific values). All 3 pass on this dev box. EndToEndIpcTests + IpcHandshakeIntegrationTests moved from Galaxy.Proxy.Tests (net10) to Galaxy.Host.Tests (net48 x86) — the previous test placement silently dropped them at xUnit discovery because Host became net48 x86 and net10 process can't load it. Rewritten to use Shared's FrameReader/FrameWriter directly instead of going through Proxy's GalaxyIpcClient (functionally equivalent — same wire protocol, framing primitives + dispatcher are the production code path verbatim). 7 IPC tests now run cleanly: Hello+heartbeat round-trip, wrong-secret rejection, OpenSession session-id assignment, Discover error-response surfacing, WriteValues per-tag bad status, Subscribe id assignment, Recycle grace window. Phase 2 exit-gate doc (docs/v2/implementation/exit-gate-phase-2.md) supersedes the partial-exit doc with the as-built state — Streams A/B/C complete; D/E gated only on the legacy-Host removal + parity-test rewrite cycle that fundamentally requires multi-day debug iteration; full adversarial-review section ranking 8 findings (2 high, 3 medium, 3 low) all explicitly deferred to Stream D/E or v2.1 with rationale; Stream-D removal checklist gives the next-session entry point with two policy options for the 494 v1 tests (rewrite-to-use-Proxy vs archive-and-write-smaller-v2-parity-suite). Cannot one-shot Stream D.1 in any single session because deleting OtOpcUa.Host requires the v1 IntegrationTests cycle to be retargeted first; that's the structural blocker, not "needs more code" — and the plan itself budgets 3-4 weeks for it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 00:23:24 -04:00

120 lines
5.1 KiB
C#

using System;
using System.IO;
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.Galaxy.Host.Backend;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Ipc;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
{
/// <summary>
/// Direct IPC handshake test — drives <see cref="PipeServer"/> with a hand-rolled client
/// built on <see cref="FrameReader"/>/<see cref="FrameWriter"/> from Shared. Stays in
/// net48 x86 alongside the Host (the Proxy's <c>GalaxyIpcClient</c> is net10 only and
/// cannot be loaded into this process). Functionally equivalent to going through
/// <c>GalaxyIpcClient</c> — proves the wire protocol + ACL + shared-secret enforcement.
/// Skipped on Administrator shells per the same PipeAcl-denies-Administrators guard.
/// </summary>
[Trait("Category", "Integration")]
public sealed class IpcHandshakeIntegrationTests
{
private static bool IsAdministrator()
{
using var identity = WindowsIdentity.GetCurrent();
return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
}
private static async Task<(NamedPipeClientStream Stream, FrameReader Reader, FrameWriter Writer)>
ConnectAndHelloAsync(string pipeName, string secret, CancellationToken ct)
{
var stream = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
await stream.ConnectAsync(5_000, ct);
var reader = new FrameReader(stream, leaveOpen: true);
var writer = new FrameWriter(stream, leaveOpen: true);
await writer.WriteAsync(MessageKind.Hello,
new Hello { PeerName = "test-client", SharedSecret = secret }, ct);
var ack = await reader.ReadFrameAsync(ct);
if (ack is null) throw new EndOfStreamException("no HelloAck");
if (ack.Value.Kind != MessageKind.HelloAck) throw new InvalidOperationException("unexpected first frame");
var ackMsg = MessagePackSerializer.Deserialize<HelloAck>(ack.Value.Body);
if (!ackMsg.Accepted) throw new UnauthorizedAccessException(ackMsg.RejectReason);
return (stream, reader, writer);
}
[Fact]
public async Task Handshake_with_correct_secret_succeeds_and_heartbeat_round_trips()
{
if (IsAdministrator()) return;
using var identity = WindowsIdentity.GetCurrent();
var sid = identity.User!;
var pipe = $"OtOpcUaGalaxyTest-{Guid.NewGuid():N}";
const string secret = "test-secret-2026";
Logger log = new LoggerConfiguration().CreateLogger();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var server = new PipeServer(pipe, sid, secret, log);
var serverTask = Task.Run(() => server.RunOneConnectionAsync(
new GalaxyFrameHandler(new StubGalaxyBackend(), log), cts.Token));
var (stream, reader, writer) = await ConnectAndHelloAsync(pipe, secret, cts.Token);
using (stream)
using (reader)
using (writer)
{
await writer.WriteAsync(MessageKind.Heartbeat,
new Heartbeat { SequenceNumber = 42, UtcUnixMs = 1000 }, cts.Token);
var hbAckFrame = await reader.ReadFrameAsync(cts.Token);
hbAckFrame.HasValue.ShouldBeTrue();
hbAckFrame!.Value.Kind.ShouldBe(MessageKind.HeartbeatAck);
MessagePackSerializer.Deserialize<HeartbeatAck>(hbAckFrame.Value.Body).SequenceNumber.ShouldBe(42L);
}
cts.Cancel();
try { await serverTask; } catch { /* shutdown */ }
server.Dispose();
}
[Fact]
public async Task Handshake_with_wrong_secret_is_rejected()
{
if (IsAdministrator()) return;
using var identity = WindowsIdentity.GetCurrent();
var sid = identity.User!;
var pipe = $"OtOpcUaGalaxyTest-{Guid.NewGuid():N}";
Logger log = new LoggerConfiguration().CreateLogger();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var server = new PipeServer(pipe, sid, "real-secret", log);
var serverTask = Task.Run(() => server.RunOneConnectionAsync(
new GalaxyFrameHandler(new StubGalaxyBackend(), log), cts.Token));
await Should.ThrowAsync<UnauthorizedAccessException>(async () =>
{
var (s, r, w) = await ConnectAndHelloAsync(pipe, "wrong-secret", cts.Token);
s.Dispose();
r.Dispose();
w.Dispose();
});
cts.Cancel();
try { await serverTask; } catch { /* shutdown */ }
server.Dispose();
}
}
}