using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Security.Principal; using System.Threading; using System.Threading.Tasks; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.LiveStack; /// /// Connects a single to the already-running /// OtOpcUaGalaxyHost Windows service for the lifetime of a test class. Uses /// to decide whether to proceed; on failure, /// is populated and each test calls /// to translate that into Assert.Skip. /// /// /// /// Does NOT spawn the Host process. Production deploys OtOpcUaGalaxyHost /// as a standalone Windows service — spawning a second instance from a test would /// bypass the COM-apartment + service-account setup and fail differently than /// production (see project_galaxy_host_service.md memory). /// /// /// Shared-secret handling: read from — env vars /// first, then the service's registry-stored Environment values. Requires /// the test process to have read access to /// HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost; on a dev box /// that typically means running the test host elevated, or exporting /// OTOPCUA_GALAXY_SECRET out-of-band. /// /// public sealed class LiveStackFixture : IAsyncLifetime { public GalaxyProxyDriver? Driver { get; private set; } public string? SkipReason { get; private set; } public PrerequisiteReport? PrerequisiteReport { get; private set; } public LiveStackConfig? Config { get; private set; } public async ValueTask InitializeAsync() { // 0. Elevated-shell short-circuit. The OtOpcUaGalaxyHost pipe ACL allows the configured // SID but explicitly DENIES Administrators (decision #76 — production hardening). // A test process running with a high-integrity token (any elevated shell) carries the // Admins group in its security context, so the deny rule trumps the user's allow and // the pipe connect returns UnauthorizedAccessException — technically correct but // the operationally confusing failure mode that ate most of the PR 37 install // debugging session. Surfacing it explicitly here saves the next operator the same // five-step diagnosis. ParityFixture has the same skip with the same rationale. if (IsElevatedAdministratorOnWindows()) { SkipReason = "Test host is running with elevated (Administrators) privileges, but the " + "OtOpcUaGalaxyHost named-pipe ACL explicitly denies Administrators per the IPC " + "security design (decision #76 / PipeAcl.cs). Re-run from a NORMAL (non-admin) " + "PowerShell window — even when your user is already in the pipe's allow list, " + "the elevated token's Admins group membership trumps the allow rule."; return; } // 1. AVEVA + OtOpcUa service state — actionable diagnostic if anything is missing. using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); PrerequisiteReport = await AvevaPrerequisites.CheckAllAsync( new AvevaPrerequisites.Options { CheckGalaxyHostPipe = true, CheckHistorian = false }, cts.Token); if (!PrerequisiteReport.IsLivetestReady) { SkipReason = PrerequisiteReport.SkipReason; return; } // 2. Secret / pipe-name resolution. If the service is running but we can't discover its // env vars from registry (non-elevated test host), a clear message beats a silent // connect-rejected failure 10 seconds later. Config = LiveStackConfig.Resolve(); if (Config is null) { SkipReason = $"Cannot resolve shared secret. Set {LiveStackConfig.EnvSharedSecret} (and optionally " + $"{LiveStackConfig.EnvPipeName}) in the environment, or run the test host elevated so it " + $"can read HKLM\\{LiveStackConfig.ServiceRegistryKey}\\Environment."; return; } // 3. Connect. InitializeAsync does the pipe connect + handshake; a 5-second // ConnectTimeout gives enough headroom for a service that just started. Driver = new GalaxyProxyDriver(new GalaxyProxyOptions { DriverInstanceId = "live-stack-smoke", PipeName = Config.PipeName, SharedSecret = Config.SharedSecret, ConnectTimeout = TimeSpan.FromSeconds(5), }); try { await Driver.InitializeAsync(driverConfigJson: "{}", CancellationToken.None); } catch (Exception ex) { SkipReason = $"Connected to named pipe '{Config.PipeName}' but GalaxyProxyDriver.InitializeAsync failed: " + $"{ex.GetType().Name}: {ex.Message}. Common causes: shared secret mismatch (rotated after last install), " + $"service account SID not in pipe ACL (installer sets OTOPCUA_ALLOWED_SID to the service account — " + $"test must run as that user), or Host's backend couldn't connect to ZB."; Driver.Dispose(); Driver = null; return; } } public async ValueTask DisposeAsync() { if (Driver is not null) { try { await Driver.ShutdownAsync(CancellationToken.None); } catch { /* best-effort */ } Driver.Dispose(); } } /// /// Translate into Assert.Skip. Tests call this at the /// top of every fact so a fixture init failure shows up as a cleanly-skipped test with /// the full prerequisites report, not a cascading NullReferenceException on /// . /// public void SkipIfUnavailable() { if (SkipReason is not null) Assert.Skip(SkipReason); } private static bool IsElevatedAdministratorOnWindows() { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return false; return CheckWindowsAdminToken(); } [SupportedOSPlatform("windows")] private static bool CheckWindowsAdminToken() { try { using var identity = WindowsIdentity.GetCurrent(); return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator); } catch { // Probe shouldn't crash the test; if we can't determine elevation, optimistically // continue and let the actual pipe connect surface its own error. return false; } } } [CollectionDefinition(Name)] public sealed class LiveStackCollection : ICollectionFixture { public const string Name = "LiveStack"; }