Compare commits
5 Commits
phase-3-pr
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa8834a231 | ||
| 976e73e051 | |||
|
|
8fb3dbe53b | ||
|
|
a61e637411 | ||
| e4885aadd0 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,3 +29,4 @@ packages/
|
||||
# Claude Code (per-developer settings, runtime lock files, agent transcripts)
|
||||
.claude/
|
||||
|
||||
.local/
|
||||
|
||||
@@ -125,14 +125,29 @@ Shared secret + pipe name resolve from `OTOPCUA_GALAXY_SECRET` /
|
||||
`OTOPCUA_GALAXY_PIPE` env vars, falling back to reading the service's
|
||||
registry-stored Environment values (requires elevated test host).
|
||||
|
||||
**Remaining**:
|
||||
- Install + run the `OtOpcUaGalaxyHost` + `OtOpcUa` services on the dev box
|
||||
(`scripts/install/Install-Services.ps1`) so the skip-on-unready tests
|
||||
actually execute and the smoke PR lands green.
|
||||
- Subscribe-and-receive-data-change fact (needs a known tag that actually
|
||||
ticks; deferred until operators confirm a scratch tag exists).
|
||||
- Write-and-roundtrip fact (needs a test-only UDA or agreed scratch tag
|
||||
so we can't accidentally mutate a process-critical value).
|
||||
**PR 40** added the write + subscribe facts targeting
|
||||
`DelmiaReceiver_001.TestAttribute` (the writable Boolean UDA the dev Galaxy
|
||||
ships under TestMachine_001) — write-then-read with a 5s scan-window poll +
|
||||
restore-on-finally, and subscribe-then-write asserting both an initial-value
|
||||
OnDataChange and a post-write OnDataChange. PR 39 added the elevated-shell
|
||||
short-circuit so a developer running from an admin window gets an actionable
|
||||
skip instead of `UnauthorizedAccessException`.
|
||||
|
||||
**Run the live tests** (from a NORMAL non-admin PowerShell):
|
||||
|
||||
```powershell
|
||||
$env:OTOPCUA_GALAXY_SECRET = Get-Content C:\Users\dohertj2\Desktop\lmxopcua\.local\galaxy-host-secret.txt
|
||||
cd C:\Users\dohertj2\Desktop\lmxopcua
|
||||
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests --filter "FullyQualifiedName~LiveStackSmokeTests"
|
||||
```
|
||||
|
||||
Expected: 7/7 pass against the running `OtOpcUaGalaxyHost` service.
|
||||
|
||||
**Remaining for #5 in production-grade form**:
|
||||
- Confirm the suite passes from a non-elevated shell (operator action).
|
||||
- Add similar facts for an alarm-source attribute once `TestMachine_001` (or
|
||||
a sibling) carries a deployed alarm condition — the current dev Galaxy's
|
||||
TestAttribute isn't alarm-flagged.
|
||||
|
||||
## 6. Second driver instance on the same server — **DONE (PR 32)**
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.Principal;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
@@ -40,6 +43,25 @@ public sealed class LiveStackFixture : IAsyncLifetime
|
||||
|
||||
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(
|
||||
@@ -111,6 +133,28 @@ public sealed class LiveStackFixture : IAsyncLifetime
|
||||
{
|
||||
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)]
|
||||
|
||||
@@ -117,6 +117,141 @@ public sealed class LiveStackSmokeTests(LiveStackFixture fixture)
|
||||
$"Investigate: the Host service's logs at {System.Environment.GetFolderPath(System.Environment.SpecialFolder.CommonApplicationData)}\\OtOpcUa\\Galaxy\\logs.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_then_read_roundtrips_a_writable_Boolean_attribute_on_TestMachine_001()
|
||||
{
|
||||
// PR 40 — finishes LMX #5. Targets DelmiaReceiver_001.TestAttribute, the writable
|
||||
// Boolean attribute on the TestMachine_001 hierarchy that the dev Galaxy was deployed
|
||||
// with for exactly this kind of integration testing. We invert the current value and
|
||||
// assert the new value comes back, then restore the original so the test is effectively
|
||||
// idempotent (Galaxy holds the value across runs since it's a deployed UDA).
|
||||
fixture.SkipIfUnavailable();
|
||||
const string fullRef = "DelmiaReceiver_001.TestAttribute";
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
|
||||
// Read current value first — gives the cleanup path the right baseline. Galaxy may
|
||||
// return Uncertain quality until the Engine has scanned the attribute at least once;
|
||||
// we don't read into a strongly-typed bool until Status is Good.
|
||||
var before = (await fixture.Driver!.ReadAsync([fullRef], cts.Token))[0];
|
||||
before.StatusCode.ShouldNotBe(0x80020000u, $"baseline read failed for {fullRef}: {before.Value}");
|
||||
var originalBool = Convert.ToBoolean(before.Value ?? false);
|
||||
var inverted = !originalBool;
|
||||
|
||||
try
|
||||
{
|
||||
// Write the inverted value via IWritable.
|
||||
var writeResults = await fixture.Driver!.WriteAsync(
|
||||
[new(fullRef, inverted)], cts.Token);
|
||||
writeResults.Count.ShouldBe(1);
|
||||
writeResults[0].StatusCode.ShouldBe(0u,
|
||||
$"WriteAsync returned status 0x{writeResults[0].StatusCode:X8} for {fullRef} — " +
|
||||
$"check the Host service log at %ProgramData%\\OtOpcUa\\Galaxy\\.");
|
||||
|
||||
// The Engine's scan + acknowledgement is async — read in a short loop with a 5s
|
||||
// budget. Galaxy's attribute roundtrip on a dev box is typically sub-second but
|
||||
// we give headroom for first-scan after a service restart.
|
||||
DataValueSnapshot after = default!;
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
after = (await fixture.Driver!.ReadAsync([fullRef], cts.Token))[0];
|
||||
if (after.StatusCode == 0u && Convert.ToBoolean(after.Value ?? false) == inverted) break;
|
||||
await Task.Delay(200, cts.Token);
|
||||
}
|
||||
after.StatusCode.ShouldBe(0u, "post-write read failed");
|
||||
Convert.ToBoolean(after.Value ?? false).ShouldBe(inverted,
|
||||
$"Wrote {inverted} but Galaxy returned {after.Value} after the scan window.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Restore — best-effort. If this throws the test still reports its primary result;
|
||||
// we just leave a flipped TestAttribute on the dev box (benign, name says it all).
|
||||
try { await fixture.Driver!.WriteAsync([new(fullRef, originalBool)], cts.Token); }
|
||||
catch { /* swallow */ }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_fires_OnDataChange_with_initial_value_then_again_after_a_write()
|
||||
{
|
||||
// Subscribe + write is the canonical "is the data path actually live" test for
|
||||
// an OPC UA driver. We subscribe to the same Boolean attribute, expect an initial-
|
||||
// value callback within a couple of seconds (per ISubscribable's contract — the
|
||||
// driver MAY fire OnDataChange immediately with the current value), then write a
|
||||
// distinct value and expect a second callback carrying the new value.
|
||||
fixture.SkipIfUnavailable();
|
||||
const string fullRef = "DelmiaReceiver_001.TestAttribute";
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
|
||||
// Capture every OnDataChange notification for this fullRef onto a thread-safe queue
|
||||
// we can poll from the test thread. Galaxy's MXAccess advisory fires on its own
|
||||
// thread; we don't want to block it.
|
||||
var notifications = new System.Collections.Concurrent.ConcurrentQueue<DataValueSnapshot>();
|
||||
void Handler(object? sender, DataChangeEventArgs e)
|
||||
{
|
||||
if (string.Equals(e.FullReference, fullRef, StringComparison.OrdinalIgnoreCase))
|
||||
notifications.Enqueue(e.Snapshot);
|
||||
}
|
||||
fixture.Driver!.OnDataChange += Handler;
|
||||
|
||||
// Read current value so we know which value to write to force a transition.
|
||||
var before = (await fixture.Driver!.ReadAsync([fullRef], cts.Token))[0];
|
||||
var originalBool = Convert.ToBoolean(before.Value ?? false);
|
||||
var toWrite = !originalBool;
|
||||
|
||||
ISubscriptionHandle? handle = null;
|
||||
try
|
||||
{
|
||||
handle = await fixture.Driver!.SubscribeAsync(
|
||||
[fullRef], TimeSpan.FromMilliseconds(250), cts.Token);
|
||||
|
||||
// Wait for initial-value notification — typical < 1s on a hot Galaxy, give 5s.
|
||||
await WaitForAsync(() => notifications.Count >= 1, TimeSpan.FromSeconds(5), cts.Token);
|
||||
notifications.Count.ShouldBeGreaterThanOrEqualTo(1,
|
||||
$"No initial-value OnDataChange for {fullRef} within 5s. " +
|
||||
$"Either MXAccess subscription failed silently or the Engine hasn't scanned yet.");
|
||||
|
||||
// Drain the initial-value queue before writing so we count post-write deltas only.
|
||||
var initialCount = notifications.Count;
|
||||
|
||||
// Write the toggled value. Engine scan + advisory fires the second callback.
|
||||
var w = await fixture.Driver!.WriteAsync([new(fullRef, toWrite)], cts.Token);
|
||||
w[0].StatusCode.ShouldBe(0u);
|
||||
|
||||
await WaitForAsync(() => notifications.Count > initialCount, TimeSpan.FromSeconds(8), cts.Token);
|
||||
notifications.Count.ShouldBeGreaterThan(initialCount,
|
||||
$"OnDataChange did not fire after writing {toWrite} to {fullRef} within 8s.");
|
||||
|
||||
// Find the post-write notification carrying the toggled value (initial value may
|
||||
// appear multiple times before the write commits — search the tail).
|
||||
var postWrite = notifications.ToArray().Reverse()
|
||||
.FirstOrDefault(n => n.StatusCode == 0u && Convert.ToBoolean(n.Value ?? false) == toWrite);
|
||||
postWrite.ShouldNotBe(default,
|
||||
$"No OnDataChange carrying the toggled value {toWrite} appeared in the queue: " +
|
||||
string.Join(",", notifications.Select(n => $"{n.Value}@{n.StatusCode:X8}")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
fixture.Driver!.OnDataChange -= Handler;
|
||||
if (handle is not null)
|
||||
{
|
||||
try { await fixture.Driver!.UnsubscribeAsync(handle, cts.Token); } catch { /* swallow */ }
|
||||
}
|
||||
// Restore baseline.
|
||||
try { await fixture.Driver!.WriteAsync([new(fullRef, originalBool)], cts.Token); } catch { /* swallow */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> predicate, TimeSpan budget, CancellationToken ct)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + budget;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (predicate()) return;
|
||||
await Task.Delay(100, ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal <see cref="IAddressSpaceBuilder"/> implementation that captures every
|
||||
/// Variable() call into a flat list so tests can inspect what discovery produced
|
||||
|
||||
Reference in New Issue
Block a user