using System; using System.Collections.Concurrent; using System.Diagnostics; using System.Linq; using System.Threading; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Worker.MxAccess; using Xunit; using Xunit.Abstractions; namespace ZB.MOM.WW.MxGateway.Worker.Tests.Probes; /// /// Live dev-rig smoke test for the MANAGED GUID-ack path — the /// wired into /// . Where AlarmsLiveSmokeTests acks by /// name through the wnwrap COM consumer, this acks by GUID through AVEVA's /// modern aaAlarmManagedClient.AlarmClient.AlarmAckByGUID — the path /// that carries operator domain + full name and is not E_NOTIMPL. /// /// Flow: subscribe a wnwrap reader + a managed ack client to /// \\<machine>\Galaxy!DEV; wait for a Raise; pull a real /// (non-degraded) alarm GUID from the reader's snapshot; ack it by GUID via /// the dispatcher (which routes to the managed client); then assert the ack /// registers as a subsequent /// transition (and that the active snapshot shows the alarm acked). /// /// This is the runtime confirmation the static work could not close: that an /// authenticated GUID ack actually flips state. rc=0 alone is "accepted", /// not "applied" — the Acknowledge transition is the real assertion. For a /// SECURED alarm the ack must run under an operator with Operate permission; /// run the dev-rig flip script and, if the test UDA is secured, an /// authenticated operator session (see docs/AlarmProbeFindings.md). /// /// Skip-gated; flip Skip=null on the dev rig with AVEVA + /// aaAlarmManagedClient.dll present and the flip script running. /// public sealed class AlarmManagedGuidAckLiveSmokeTests { private static readonly string SubscriptionExpression = $@"\\{Environment.MachineName}\Galaxy!DEV"; private static readonly TimeSpan TransitionWaitTimeout = TimeSpan.FromSeconds(20); private static readonly TimeSpan RaiseWait = TimeSpan.FromSeconds(30); private static readonly TimeSpan AckConfirmWait = TimeSpan.FromSeconds(30); private const string SessionId = "alarms-managed-guid-ack-smoke"; private readonly ITestOutputHelper output; private readonly Stopwatch elapsed = Stopwatch.StartNew(); private readonly ConcurrentQueue log = new ConcurrentQueue(); /// Initializes a new instance of the AlarmManagedGuidAckLiveSmokeTests class. /// Test output helper for logging. public AlarmManagedGuidAckLiveSmokeTests(ITestOutputHelper output) { this.output = output; } /// Acks a live alarm by GUID through the managed client and verifies the acked transition. [Fact(Skip = "Live dev-rig smoke test — flip Skip=null with AVEVA + aaAlarmManagedClient.dll + the alarm flip script, and an operator authorized to ack.")] public void ManagedGuidAck_AcksActiveAlarm_RegistersAcknowledge() { Exception? threadException = null; var done = new ManualResetEventSlim(false); var thread = new Thread(() => { try { RunSmoke(); } catch (Exception ex) { threadException = ex; } finally { done.Set(); } }); thread.IsBackground = false; thread.SetApartmentState(ApartmentState.STA); thread.Start(); done.Wait(); thread.Join(); output.WriteLine($"Captured {log.Count} log line(s):"); while (log.TryDequeue(out string? line)) output.WriteLine(line); if (threadException != null) throw threadException; } private void RunSmoke() { Log($"Subscription expression: {SubscriptionExpression}"); MxAccessEventQueue queue = new MxAccessEventQueue(); WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer( new WNWRAPCONSUMERLib.wwAlarmConsumerClass(), maxAlarmsPerFetch: 1024); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); // Construct the managed ack client directly (production selects it via // AaManagedAlarmAckConsumer.EnvGatedFactory when MXGATEWAY_USE_MANAGED_ALARM_ACK=1). using IManagedAlarmAckClient managedAck = new AaManagedAlarmAckConsumer(SessionId + ".ack"); using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, managedAck); Log("Constructed reader + managed ack client + dispatcher."); dispatcher.Subscribe(SubscriptionExpression); Log("Subscribe (reader + managed ack) -> ok. Driving PollOnce from this STA..."); // 1. Wait for a Raise so there is an active, unacked alarm to ack. OnAlarmTransitionEvent? raiseBody = null; DateTime raiseDeadline = DateTime.UtcNow + RaiseWait; while (DateTime.UtcNow < raiseDeadline && raiseBody is null) { WorkerEvent? evt = WaitForTransition(queue, TransitionWaitTimeout, "raise", consumer); if (evt is null) break; OnAlarmTransitionEvent body = evt.Event.OnAlarmTransition; Log("Transition: " + DescribeTransition(body)); Assert.Equal(SessionId, evt.Event.SessionId); if (body.TransitionKind == AlarmTransitionKind.Raise) raiseBody = body; } Assert.NotNull(raiseBody); // 2. Pull a REAL alarm GUID from the reader's snapshot (alarmmgr mode). // Skip degraded/subtag records — their GUID is synthetic and not // valid for AlarmAckByGUID. var records = consumer.SnapshotActiveAlarms(out _); Log($"Snapshot records: {records.Count}"); foreach (var r in records) Log($" rec guid={r.AlarmGuid} state={r.State} degraded={r.Degraded} ref={r.ProviderName}!{r.Group}.{r.TagName}"); MxAlarmSnapshotRecord? target = records.FirstOrDefault(r => !r.Degraded && r.AlarmGuid != Guid.Empty && r.State == MxAlarmStateKind.UnackAlm); Assert.NotNull(target); Log($"Ack target GUID={target!.AlarmGuid} ref={target.ProviderName}!{target.Group}.{target.TagName}"); // 3. Ack by GUID through the dispatcher -> managed AlarmAckByGUID, with // the current Windows identity (domain + full name are carried // natively on this path, unlike the wnwrap 6-arg AckByName). string user = Environment.UserName; string node = Environment.MachineName; string domain = Environment.UserDomainName ?? string.Empty; Log($"Ack identity user='{user}' node='{node}' domain='{domain}'"); int rc = dispatcher.Acknowledge( alarmGuid: target.AlarmGuid, ackComment: "managed-guid-ack smoke", ackOperatorName: user, ackOperatorNode: node, ackOperatorDomain: domain, ackOperatorFullName: user); Log($"dispatcher.Acknowledge(managed GUID) -> rc={rc}"); Assert.Equal(0, rc); // 4. The ack must APPLY, not merely be accepted: wait for an // Acknowledge transition on the same alarm, or the snapshot showing // it acked. rc=0 with neither is the inert-ack failure the probe saw. bool acked = false; DateTime confirmDeadline = DateTime.UtcNow + AckConfirmWait; while (DateTime.UtcNow < confirmDeadline && !acked) { WorkerEvent? evt = WaitForTransition(queue, TransitionWaitTimeout, "post-ack", consumer); if (evt is not null) { OnAlarmTransitionEvent body = evt.Event.OnAlarmTransition; Log("Post-ack transition: " + DescribeTransition(body)); if (body.TransitionKind == AlarmTransitionKind.Acknowledge) { acked = true; break; } } // Also accept the active snapshot flipping to an acked state for the // target GUID, in case the ack delta was folded without a discrete // Acknowledge transition. var now = consumer.SnapshotActiveAlarms(out _); if (now.Any(r => r.AlarmGuid == target.AlarmGuid && (r.State == MxAlarmStateKind.AckAlm || !string.IsNullOrEmpty(r.OperatorName)))) { Log("Snapshot shows target GUID acked (AckAlm / operator populated)."); acked = true; } } Assert.True( acked, "Managed GUID ack returned rc=0 but no Acknowledge transition or acked snapshot was observed " + "within the confirm window — the ack was accepted but not applied (see docs/AlarmProbeFindings.md; " + "most likely the alarm's MxSecurityOperate classification requires an authenticated operator)."); } private WorkerEvent? WaitForTransition( MxAccessEventQueue queue, TimeSpan timeout, string label, WnWrapAlarmConsumer consumer) { DateTime deadline = DateTime.UtcNow + timeout; int pollCount = 0; while (DateTime.UtcNow < deadline) { try { consumer.PollOnce(); pollCount++; } catch (Exception ex) { Log($"PollOnce threw on poll #{pollCount + 1}: {ex.GetType().Name}: {ex.Message}"); throw; } if (queue.TryDequeue(out WorkerEvent? evt) && evt is not null) { if (evt.Event.Family == MxEventFamily.OnAlarmTransition) return evt; Log($"Skipped non-alarm event (family={evt.Event.Family}) while waiting for {label}."); } Thread.Sleep(500); } Log($"Timed out waiting for {label} transition after {timeout.TotalSeconds:F0}s (polls={pollCount})."); return null; } private static string DescribeTransition(OnAlarmTransitionEvent body) { return string.Format( "kind={0} ref='{1}' operator='{2}' comment='{3}' ts={4:o}", body.TransitionKind, body.AlarmFullReference, body.OperatorUser, body.OperatorComment, body.TransitionTimestamp?.ToDateTime() ?? DateTime.MinValue); } private void Log(string line) => log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}"); }