PR 39 closes the gap. New IsElevatedAdministratorOnWindows static helper (Windows-only via RuntimeInformation.IsOSPlatform; non-Windows hosts return false and let the prerequisite probe own the skip-with-reason path) checks WindowsPrincipal.IsInRole(WindowsBuiltInRole.Administrator) on the current process token. When true, InitializeAsync short-circuits to a SkipReason that names the cause directly: 'elevated token's Admins group membership trumps the allow rule — re-run from a NORMAL (non-admin) PowerShell window'. Catches and swallows any probe-side exception so a Win32 oddity can't crash the test fixture; failed probe falls through to the regular prerequisite path. The check fires BEFORE AvevaPrerequisites.CheckAllAsync runs because the prereq probe's own pipe connect hits the same admin-deny and surfaces UnauthorizedAccessException with no context. Short-circuiting earlier saves the 10-second probe + produces a single actionable line. Tests — verified manually from an elevated bash session against the just-installed OtOpcUaGalaxyHost service: skip message reads '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.' Proxy.Tests Unit: 17 pass / 0 fail (unchanged — fixture change is non-breaking; existing tests don't run as admin in normal CI flow). Build clean. Bonus: gitignored .local/ directory (a previous direct commit on local v2 that I'm now landing here) so per-install secrets like the Galaxy.Host shared-secret file don't leak into the repo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
165 lines
7.1 KiB
C#
165 lines
7.1 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Connects a single <see cref="GalaxyProxyDriver"/> to the already-running
|
|
/// <c>OtOpcUaGalaxyHost</c> Windows service for the lifetime of a test class. Uses
|
|
/// <see cref="AvevaPrerequisites"/> to decide whether to proceed; on failure,
|
|
/// <see cref="SkipReason"/> is populated and each test calls <see cref="SkipIfUnavailable"/>
|
|
/// to translate that into <c>Assert.Skip</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Does NOT spawn the Host process.</b> Production deploys <c>OtOpcUaGalaxyHost</c>
|
|
/// 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 <c>project_galaxy_host_service.md</c> memory).
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Shared-secret handling</b>: read from <see cref="LiveStackConfig"/> — env vars
|
|
/// first, then the service's registry-stored <c>Environment</c> values. Requires
|
|
/// the test process to have read access to
|
|
/// <c>HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost</c>; on a dev box
|
|
/// that typically means running the test host elevated, or exporting
|
|
/// <c>OTOPCUA_GALAXY_SECRET</c> out-of-band.
|
|
/// </para>
|
|
/// </remarks>
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Translate <see cref="SkipReason"/> into <c>Assert.Skip</c>. 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
|
|
/// <see cref="Driver"/>.
|
|
/// </summary>
|
|
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<LiveStackFixture>
|
|
{
|
|
public const string Name = "LiveStack";
|
|
}
|