d6db0ff40d
AlarmCommandHandler -> AlarmDispatcher -> IAlarmAckWriter: the dispatcher resolves the ack item through the consumer and the writer authenticates the operator and writes the comment. The executor builds AlarmAckOperator from operator_user/domain/password, maps AlarmAckOutcome to the reply (Applied/Unconfirmed Ok, AlarmNotFound/NotAckable InvalidRequest, AuthenticationFailed/WriteRejected MxaccessFailure) and reports escaping exceptions by type and HRESULT only, so operator_password cannot leak. Removes the inert managed/WNAL alarm-client ack path. Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
276 lines
12 KiB
C#
276 lines
12 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Diagnostics;
|
|
using System.Threading;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
|
using Xunit.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Worker.Tests.Probes;
|
|
|
|
/// <summary>
|
|
/// Live dev-rig smoke test for the alarms-over-gateway pipeline.
|
|
/// Exercises <see cref="WnWrapAlarmConsumer"/> + <see cref="AlarmDispatcher"/> +
|
|
/// <see cref="MxAccessAlarmEventSink"/> end-to-end against the actual
|
|
/// AVEVA System Platform install: subscribes to
|
|
/// <c>\\<machine>\Galaxy!DEV</c>, waits for at least one alarm
|
|
/// transition (the dev rig's flip script writes
|
|
/// <c>TestMachine_001.TestAlarm001</c> every 10s), drains the proto
|
|
/// <c>OnAlarmTransitionEvent</c> from the queue, then ack-by-name's
|
|
/// it and verifies the ack registers as a subsequent
|
|
/// <see cref="AlarmTransitionKind.Acknowledge"/> transition.
|
|
///
|
|
/// Skip-gated; flip <c>Skip=null</c> on the dev rig with the flip
|
|
/// script running.
|
|
/// </summary>
|
|
public sealed class AlarmsLiveSmokeTests
|
|
{
|
|
private static readonly string SubscriptionExpression =
|
|
$@"\\{Environment.MachineName}\Galaxy!DEV";
|
|
private static readonly TimeSpan PumpDuration = TimeSpan.FromSeconds(45);
|
|
private static readonly TimeSpan TransitionWaitTimeout = TimeSpan.FromSeconds(20);
|
|
|
|
private const string SessionId = "alarms-live-smoke";
|
|
|
|
private readonly ITestOutputHelper output;
|
|
private readonly Stopwatch elapsed = Stopwatch.StartNew();
|
|
private readonly ConcurrentQueue<string> log = new ConcurrentQueue<string>();
|
|
|
|
/// <summary>Initializes a new instance of the AlarmsLiveSmokeTests class.</summary>
|
|
/// <param name="output">Test output helper for logging.</param>
|
|
public AlarmsLiveSmokeTests(ITestOutputHelper output)
|
|
{
|
|
this.output = output;
|
|
}
|
|
|
|
/// <summary>Verifies the alarm pipeline raises and acknowledges alarms correctly.</summary>
|
|
[Fact(Skip = "Live dev-rig smoke test — flip Skip=null with AVEVA + the alarm flip script running. Verified working 2026-05-01.")]
|
|
public void Alarms_FullPipelineRoundTrip_RaisesAndAcknowledges()
|
|
{
|
|
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}");
|
|
Log($"Pump duration: {PumpDuration.TotalSeconds:F0}s; transition wait timeout: {TransitionWaitTimeout.TotalSeconds:F0}s");
|
|
|
|
MxAccessEventQueue queue = new MxAccessEventQueue();
|
|
// The consumer owns no internal timer; we drive PollOnce manually
|
|
// from the STA below (the wnwrap COM is ThreadingModel=Apartment,
|
|
// and this test doesn't run a Win32 message pump on its STA).
|
|
WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(
|
|
new WNWRAPCONSUMERLib.wwAlarmConsumerClass(),
|
|
maxAlarmsPerFetch: 1024);
|
|
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
|
|
using AlarmAckWriter ackWriter = new AlarmAckWriter(
|
|
new MxAccessComObjectFactory(),
|
|
() => new ZB.MOM.WW.MxGateway.Worker.Sta.StaMessagePump().PumpPendingMessages(),
|
|
TimeSpan.FromSeconds(5),
|
|
AlarmAckWriter.DefaultClientName);
|
|
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, ackWriter);
|
|
|
|
Log("Constructed consumer + sink + dispatcher.");
|
|
dispatcher.Subscribe(SubscriptionExpression);
|
|
Log("Subscribe -> ok. Driving PollOnce manually from this STA...");
|
|
|
|
// The wnwrap COM object is ThreadingModel=Apartment. The consumer
|
|
// owns no internal timer, so we drive PollOnce manually here on the
|
|
// STA. Production hosting routes polls through the worker's
|
|
// StaRuntime.
|
|
|
|
// 1. Wait for the first transition (any kind), then keep waiting
|
|
// for one with kind=Raise so the alarm is currently Active when
|
|
// we try to ack. AVEVA rejects acks of cleared alarms with -55,
|
|
// so we have to time the ack against the flip script's 10s
|
|
// cadence.
|
|
OnAlarmTransitionEvent? raiseBody = null;
|
|
DateTime raiseDeadline = DateTime.UtcNow + TimeSpan.FromSeconds(30);
|
|
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);
|
|
Assert.False(string.IsNullOrEmpty(raiseBody!.AlarmFullReference));
|
|
Assert.Contains("Galaxy", raiseBody.AlarmFullReference);
|
|
|
|
// 2. Snapshot the active set + verify the captured alarm is there.
|
|
var snapshot = dispatcher.SnapshotActiveAlarms(out _);
|
|
Log($"SnapshotActiveAlarms count={snapshot.Count}");
|
|
foreach (var s in snapshot)
|
|
{
|
|
Log(" active: " + DescribeSnapshot(s));
|
|
}
|
|
Assert.NotEmpty(snapshot);
|
|
Assert.Contains(snapshot, s => s.AlarmFullReference == raiseBody.AlarmFullReference);
|
|
|
|
// 3. Ack-by-name using the captured reference. Parse the reference
|
|
// via the same convention the gateway dispatcher uses
|
|
// (Provider!Group.Tag where the tag may contain dots).
|
|
Assert.True(TryParseReference(
|
|
raiseBody.AlarmFullReference,
|
|
out string provider, out string group, out string alarmName),
|
|
$"Captured reference '{raiseBody.AlarmFullReference}' did not parse as Provider!Group.Tag.");
|
|
Log($"Ack target: provider='{provider}' group='{group}' name='{alarmName}'");
|
|
|
|
// The ack authenticates a Galaxy operator and writes <tag>.AckMsg.
|
|
// Operator credentials come from the environment (never logged);
|
|
// the user defaults to the current process identity.
|
|
AlarmAckOperator ackOperator = new AlarmAckOperator
|
|
{
|
|
UserName = Environment.GetEnvironmentVariable("MXGATEWAY_PROBE_ACK_USER") ?? Environment.UserName,
|
|
Password = Environment.GetEnvironmentVariable("MXGATEWAY_PROBE_ACK_PASSWORD") ?? string.Empty,
|
|
};
|
|
Log($"Ack identity: {ackOperator}");
|
|
|
|
AlarmAckResult result = dispatcher.AcknowledgeByName(
|
|
alarmName, provider, group, "alarms-live-smoke ack", ackOperator);
|
|
Log($"AcknowledgeByName -> outcome={result.Outcome} native={result.NativeStatus} diag='{result.Diagnostic}'");
|
|
|
|
Assert.True(
|
|
result.Outcome == AlarmAckOutcome.Applied || result.Outcome == AlarmAckOutcome.Unconfirmed,
|
|
$"Ack outcome {result.Outcome}: {result.Diagnostic}");
|
|
|
|
// 4. Wait for the post-ack transition. With the alarm flipping every
|
|
// 10s and the consumer polling every 500ms, the next state
|
|
// change should be either kind=Acknowledge (the ack we just
|
|
// sent registered as a state delta UnackAlm → AckAlm) or the
|
|
// flip script's next Clear (UnackAlm → UnackRtn).
|
|
WorkerEvent? second = WaitForTransition(queue, TransitionWaitTimeout, "post-ack", consumer);
|
|
Assert.NotNull(second);
|
|
OnAlarmTransitionEvent secondBody = second!.Event.OnAlarmTransition;
|
|
Log("Post-ack transition: " + DescribeTransition(secondBody));
|
|
Assert.NotEqual(AlarmTransitionKind.Unspecified, secondBody.TransitionKind);
|
|
|
|
// 5. Pump a little longer to confirm the consumer keeps reporting
|
|
// transitions on the 10s flip cadence.
|
|
DateTime deadline = DateTime.UtcNow + PumpDuration;
|
|
int additional = 0;
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
consumer.PollOnce();
|
|
if (queue.TryDequeue(out WorkerEvent? evt) && evt is not null)
|
|
{
|
|
additional++;
|
|
OnAlarmTransitionEvent body = evt.Event.OnAlarmTransition;
|
|
Log($" +{additional}: " + DescribeTransition(body));
|
|
}
|
|
Thread.Sleep(500);
|
|
}
|
|
Log($"Pump completed; additional transitions captured: {additional}.");
|
|
}
|
|
|
|
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++;
|
|
if (pollCount == 1) Log("First PollOnce returned without throw.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log($"PollOnce threw on poll #{pollCount + 1}: {ex.GetType().Name}: {ex.Message}");
|
|
if (ex is System.Runtime.InteropServices.COMException ce)
|
|
{
|
|
Log($" HResult=0x{(uint)ce.HResult:X8}");
|
|
}
|
|
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 (poll count={pollCount}).");
|
|
return null;
|
|
}
|
|
|
|
private static bool TryParseReference(
|
|
string reference,
|
|
out string provider,
|
|
out string group,
|
|
out string alarmName)
|
|
{
|
|
provider = group = alarmName = string.Empty;
|
|
if (string.IsNullOrWhiteSpace(reference)) return false;
|
|
int bang = reference.IndexOf('!');
|
|
if (bang <= 0 || bang == reference.Length - 1) return false;
|
|
string left = reference.Substring(0, bang);
|
|
string right = reference.Substring(bang + 1);
|
|
int dot = right.IndexOf('.');
|
|
if (dot <= 0 || dot == right.Length - 1) return false;
|
|
provider = left;
|
|
group = right.Substring(0, dot);
|
|
alarmName = right.Substring(dot + 1);
|
|
return true;
|
|
}
|
|
|
|
private static string DescribeTransition(OnAlarmTransitionEvent body)
|
|
{
|
|
return string.Format(
|
|
"kind={0} ref='{1}' source='{2}' type='{3}' severity={4} operator='{5}' comment='{6}' ts={7:o}",
|
|
body.TransitionKind, body.AlarmFullReference, body.SourceObjectReference,
|
|
body.AlarmTypeName, body.Severity, body.OperatorUser, body.OperatorComment,
|
|
body.TransitionTimestamp?.ToDateTime() ?? DateTime.MinValue);
|
|
}
|
|
|
|
private static string DescribeSnapshot(ActiveAlarmSnapshot s)
|
|
{
|
|
return string.Format(
|
|
"ref='{0}' state={1} severity={2} operator='{3}' comment='{4}' ts={5:o}",
|
|
s.AlarmFullReference, s.CurrentState, s.Severity, s.OperatorUser,
|
|
s.OperatorComment,
|
|
s.LastTransitionTimestamp?.ToDateTime() ?? DateTime.MinValue);
|
|
}
|
|
|
|
private void Log(string line)
|
|
{
|
|
log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}");
|
|
}
|
|
}
|