Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ParityFixture.cs
Joseph Doherty d11dd0520b Galaxy IPC unblock — live dev-box E2E path
Three root-cause fixes to get an elevated dev-box shell past session open
through to real MXAccess reads:

1. PipeAcl — drop BUILTIN\Administrators deny ACE. UAC's filtered token
   carries the Admins SID as deny-only, so the deny fired even from
   non-elevated admin-account shells. The per-connection SID check in
   PipeServer.VerifyCaller remains the real authorization boundary.

2. PipeServer — swap the Hello-read / VerifyCaller order. ImpersonateNamedPipeClient
   returns ERROR_CANNOT_IMPERSONATE until at least one frame has been read
   from the pipe; reading Hello first satisfies that rule. Previously the
   ACL deny-first path masked this race — removing the deny ACE exposed it.

3. GalaxyIpcClient — add a background reader + single pending-response
   slot. A RuntimeStatusChange event between OpenSessionRequest and
   OpenSessionResponse used to satisfy the caller's single ReadFrameAsync
   and fail CallAsync with "Expected OpenSessionResponse, got
   RuntimeStatusChange". The reader now routes response kinds (and
   ErrorResponse) to the pending TCS and everything else to a handler the
   driver registers in InitializeAsync. The Proxy was already set up to
   raise managed events from RaiseDataChange / RaiseAlarmEvent /
   OnHostConnectivityUpdate — those helpers had no caller until now.

4. RedundancyPublisherHostedService — swallow BadServerHalted while
   polling host.Server.CurrentInstance. StandardServer throws that code
   during startup rather than returning null, so the first poll attempt
   crashed the BackgroundService (and the host) before OnServerStarted
   ran. This race was latent behind the Galaxy init failure above.

Updates docs that described the Admins deny ACE + mandatory non-elevated
shells, and drops the admin-skip guards from every Galaxy integration +
E2E fixture that had them (IpcHandshakeIntegrationTests, EndToEndIpcTests,
ParityFixture, LiveStackFixture, HostSubprocessParityTests).

Adds GalaxyIpcClientRoutingTests covering the router's
request/response match, ErrorResponse, event-between-call, idle event,
and peer-close paths.

Verified live on the dev box against the p7-smoke cluster (gen 6):
driver registered=1 failedInit=0, Phase 7 bridge subscribed, OPC UA
server up on 4840, MXAccess read round-trip returns real data with
Status=0x00000000.

Task #112 — partial: Galaxy live stack is functional end-to-end. The
supplied test-galaxy.ps1 script still fails because the UNS walker
encodes TagConfig JSON as the tag's NodeId instead of the seeded TagId
(pre-existing; separate issue from this commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:30:16 -04:00

128 lines
4.9 KiB
C#

using System.Diagnostics;
using System.Net.Sockets;
using System.Reflection;
using System.Security.Principal;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E;
/// <summary>
/// Spawns one <c>OtOpcUa.Driver.Galaxy.Host.exe</c> subprocess per test class and exposes
/// a connected <see cref="GalaxyProxyDriver"/> for the tests. Per Phase 2 plan §"Stream E
/// Parity Validation": the Proxy owns a session against a real out-of-process Host running
/// the production-shape <c>MxAccessGalaxyBackend</c> backed by live ZB + MXAccess COM.
/// Skipped when the Host EXE isn't built or when ZB SQL is unreachable.
/// </summary>
public sealed class ParityFixture : IAsyncLifetime
{
public GalaxyProxyDriver? Driver { get; private set; }
public string? SkipReason { get; private set; }
private Process? _host;
private const string Secret = "parity-suite-secret";
public async ValueTask InitializeAsync()
{
if (!OperatingSystem.IsWindows()) { SkipReason = "Windows-only"; return; }
if (!await ZbReachableAsync()) { SkipReason = "Galaxy ZB SQL not reachable on localhost:1433"; return; }
var hostExe = FindHostExe();
if (hostExe is null) { SkipReason = "Galaxy.Host EXE not built — run `dotnet build src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host`"; return; }
// Use the SQL-only DB backend by default — exercises the full IPC + dispatcher + SQL
// path without requiring a healthy MXAccess connection. Tests that need MXAccess
// override via env vars before InitializeAsync is called (use a separate fixture).
var pipe = $"OtOpcUaGalaxyParity-{Guid.NewGuid():N}";
using var identity = WindowsIdentity.GetCurrent();
var sid = identity.User!;
var psi = new ProcessStartInfo(hostExe)
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
EnvironmentVariables =
{
["OTOPCUA_GALAXY_PIPE"] = pipe,
["OTOPCUA_ALLOWED_SID"] = sid.Value,
["OTOPCUA_GALAXY_SECRET"] = Secret,
["OTOPCUA_GALAXY_BACKEND"] = "db",
["OTOPCUA_GALAXY_ZB_CONN"] = "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;",
},
};
_host = Process.Start(psi)
?? throw new InvalidOperationException("Failed to spawn Galaxy.Host EXE");
// Give the PipeServer ~2s to bind. The supervisor's HeartbeatMonitor can do this
// in production with retry, but the parity tests are best served by a fixed warm-up.
await Task.Delay(2_000);
Driver = new GalaxyProxyDriver(new GalaxyProxyOptions
{
DriverInstanceId = "parity",
PipeName = pipe,
SharedSecret = Secret,
ConnectTimeout = TimeSpan.FromSeconds(5),
});
await Driver.InitializeAsync(driverConfigJson: "{}", CancellationToken.None);
}
public async ValueTask DisposeAsync()
{
if (Driver is not null)
{
try { await Driver.ShutdownAsync(CancellationToken.None); } catch { /* shutdown */ }
Driver.Dispose();
}
if (_host is not null && !_host.HasExited)
{
try { _host.Kill(entireProcessTree: true); } catch { /* ignore */ }
try { _host.WaitForExit(5_000); } catch { /* ignore */ }
}
_host?.Dispose();
}
/// <summary>Skip the test if the fixture couldn't initialize. xUnit Skip.If pattern.</summary>
public void SkipIfUnavailable()
{
if (SkipReason is not null)
Assert.Skip(SkipReason);
}
private static async Task<bool> ZbReachableAsync()
{
try
{
using var client = new TcpClient();
var task = client.ConnectAsync("localhost", 1433);
return await Task.WhenAny(task, Task.Delay(1_500)) == task && client.Connected;
}
catch { return false; }
}
private static string? FindHostExe()
{
var asmDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
var solutionRoot = asmDir;
for (var i = 0; i < 8 && solutionRoot is not null; i++)
{
if (File.Exists(Path.Combine(solutionRoot, "ZB.MOM.WW.OtOpcUa.slnx"))) break;
solutionRoot = Path.GetDirectoryName(solutionRoot);
}
if (solutionRoot is null) return null;
var path = Path.Combine(solutionRoot,
"src", "ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host", "bin", "Debug", "net48",
"OtOpcUa.Driver.Galaxy.Host.exe");
return File.Exists(path) ? path : null;
}
}
[CollectionDefinition(nameof(ParityCollection))]
public sealed class ParityCollection : ICollectionFixture<ParityFixture> { }