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>
124 lines
5.0 KiB
C#
124 lines
5.0 KiB
C#
using System.Diagnostics;
|
|
using System.Reflection;
|
|
using System.Security.Principal;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Ipc;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests;
|
|
|
|
/// <summary>
|
|
/// The honest cross-FX parity test — spawns the actual <c>OtOpcUa.Driver.Galaxy.Host.exe</c>
|
|
/// subprocess (net48 x86), the Proxy connects via real named pipe, exercises Discover
|
|
/// against the live Galaxy ZB DB, and asserts gobjects come back. This is the production
|
|
/// deployment shape (Tier C: separate process, IPC over named pipe, Proxy in the .NET 10
|
|
/// server process). Skipped when the Host EXE isn't built or Galaxy is unreachable.
|
|
/// </summary>
|
|
[Trait("Category", "ProcessSpawnParity")]
|
|
public sealed class HostSubprocessParityTests : IDisposable
|
|
{
|
|
private Process? _hostProcess;
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_hostProcess is not null && !_hostProcess.HasExited)
|
|
{
|
|
try { _hostProcess.Kill(entireProcessTree: true); } catch { /* ignore */ }
|
|
try { _hostProcess.WaitForExit(5_000); } catch { /* ignore */ }
|
|
}
|
|
_hostProcess?.Dispose();
|
|
}
|
|
|
|
private static string? FindHostExe()
|
|
{
|
|
// The test assembly lives at tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/bin/Debug/net10.0/.
|
|
// The Host EXE lives at src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/bin/Debug/net48/.
|
|
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 candidate = Path.Combine(solutionRoot,
|
|
"src", "ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host", "bin", "Debug", "net48",
|
|
"OtOpcUa.Driver.Galaxy.Host.exe");
|
|
return File.Exists(candidate) ? candidate : null;
|
|
}
|
|
|
|
private static async Task<bool> ZbReachableAsync()
|
|
{
|
|
try
|
|
{
|
|
using var client = new System.Net.Sockets.TcpClient();
|
|
var task = client.ConnectAsync("localhost", 1433);
|
|
return await Task.WhenAny(task, Task.Delay(1_500)) == task && client.Connected;
|
|
}
|
|
catch { return false; }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Spawned_Host_in_db_mode_lets_Proxy_Discover_real_Galaxy_gobjects()
|
|
{
|
|
if (!OperatingSystem.IsWindows()) return;
|
|
if (!await ZbReachableAsync()) return;
|
|
|
|
var hostExe = FindHostExe();
|
|
if (hostExe is null) return; // skip when the Host hasn't been built
|
|
|
|
using var identity = WindowsIdentity.GetCurrent();
|
|
var sid = identity.User!;
|
|
var pipeName = $"OtOpcUaGalaxyParity-{Guid.NewGuid():N}";
|
|
const string secret = "parity-secret";
|
|
|
|
var psi = new ProcessStartInfo(hostExe)
|
|
{
|
|
UseShellExecute = false,
|
|
CreateNoWindow = true,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
EnvironmentVariables =
|
|
{
|
|
["OTOPCUA_GALAXY_PIPE"] = pipeName,
|
|
["OTOPCUA_ALLOWED_SID"] = sid.Value,
|
|
["OTOPCUA_GALAXY_SECRET"] = secret,
|
|
["OTOPCUA_GALAXY_BACKEND"] = "db", // SQL-only — doesn't need MXAccess
|
|
["OTOPCUA_GALAXY_ZB_CONN"] = "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;",
|
|
},
|
|
};
|
|
|
|
_hostProcess = Process.Start(psi)
|
|
?? throw new InvalidOperationException("Failed to spawn Galaxy.Host");
|
|
|
|
// Wait for the pipe to come up — the Host's PipeServer takes ~100ms to bind.
|
|
await Task.Delay(2_000);
|
|
|
|
await using var client = await GalaxyIpcClient.ConnectAsync(
|
|
pipeName, secret, TimeSpan.FromSeconds(5), CancellationToken.None);
|
|
|
|
var sessionResp = await client.CallAsync<OpenSessionRequest, OpenSessionResponse>(
|
|
MessageKind.OpenSessionRequest,
|
|
new OpenSessionRequest { DriverInstanceId = "parity", DriverConfigJson = "{}" },
|
|
MessageKind.OpenSessionResponse,
|
|
CancellationToken.None);
|
|
sessionResp.Success.ShouldBeTrue(sessionResp.Error);
|
|
|
|
var discoverResp = await client.CallAsync<DiscoverHierarchyRequest, DiscoverHierarchyResponse>(
|
|
MessageKind.DiscoverHierarchyRequest,
|
|
new DiscoverHierarchyRequest { SessionId = sessionResp.SessionId },
|
|
MessageKind.DiscoverHierarchyResponse,
|
|
CancellationToken.None);
|
|
|
|
discoverResp.Success.ShouldBeTrue(discoverResp.Error);
|
|
discoverResp.Objects.Length.ShouldBeGreaterThan(0,
|
|
"live Galaxy ZB has at least one deployed gobject");
|
|
|
|
await client.SendOneWayAsync(MessageKind.CloseSessionRequest,
|
|
new CloseSessionRequest { SessionId = sessionResp.SessionId }, CancellationToken.None);
|
|
}
|
|
}
|