137 lines
5.3 KiB
C#
137 lines
5.3 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, when ZB SQL is unreachable, or when the dev box
|
|
/// runs as Administrator (the IPC ACL explicitly denies Administrators per decision #76).
|
|
/// </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 (IsAdministrator()) { SkipReason = "PipeAcl denies Administrators on dev shells"; 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 bool IsAdministrator()
|
|
{
|
|
if (!OperatingSystem.IsWindows()) return false;
|
|
using var identity = WindowsIdentity.GetCurrent();
|
|
return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
|
|
}
|
|
|
|
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> { }
|