feat(worker): route alarm acks through the operator-authenticated AckMsg writer
ci / nightly-windev (push) Has been skipped
ci / java (push) Failing after 2m5s
ci / windows-x86 (push) Successful in 2m10s
ci / portable (push) Failing after 2m17s

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
This commit is contained in:
Joseph Doherty
2026-10-19 19:35:55 -04:00
parent 0d4b2aea2d
commit d6db0ff40d
14 changed files with 695 additions and 1282 deletions
@@ -116,7 +116,7 @@ public sealed class AlarmCommandExecutorTests
[Fact]
public void AcknowledgeAlarm_WithHandler_RoutesNativeStatusIntoHresultAndPayload()
{
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = 0 };
FakeAlarmHandler handler = new FakeAlarmHandler();
MxAccessCommandExecutor executor = NewExecutor(handler);
Guid g = Guid.NewGuid();
@@ -143,7 +143,10 @@ public sealed class AlarmCommandExecutorTests
Assert.NotNull(reply.AcknowledgeAlarm);
Assert.Equal(0, reply.AcknowledgeAlarm.NativeStatus);
Assert.Equal(g, handler.LastAckGuid);
Assert.Equal("alice", handler.LastAckOperatorName);
Assert.Equal("ack", handler.LastComment);
Assert.Equal("alice", handler.LastOperator!.UserName);
Assert.Equal("CORP", handler.LastOperator.Domain);
Assert.Equal(string.Empty, handler.LastOperator.Password);
}
/// <summary>Verifies that invalid alarm GUID returns invalid request.</summary>
@@ -168,11 +171,14 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
}
/// <summary>Verifies that nonzero native status carries a diagnostic message.</summary>
/// <summary>Verifies that a rejected write carries its native status and diagnostic.</summary>
[Fact]
public void AcknowledgeAlarm_WithNonzeroNativeStatus_CarriesDiagnostic()
{
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = -123 };
FakeAlarmHandler handler = new FakeAlarmHandler
{
AckResult = AlarmAckResult.Create(AlarmAckOutcome.WriteRejected, -123, "AckMsg write rejected: detail -123."),
};
MxAccessCommandExecutor executor = NewExecutor(handler);
StaCommand command = new StaCommand(
@@ -188,7 +194,9 @@ public sealed class AlarmCommandExecutorTests
MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
Assert.Equal(-123, reply.Hresult);
Assert.Equal(-123, reply.AcknowledgeAlarm.NativeStatus);
Assert.Contains("-123", reply.DiagnosticMessage);
}
@@ -196,7 +204,7 @@ public sealed class AlarmCommandExecutorTests
[Fact]
public void AcknowledgeAlarmByName_WithHandler_RoutesTupleToHandler()
{
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = 0 };
FakeAlarmHandler handler = new FakeAlarmHandler();
MxAccessCommandExecutor executor = NewExecutor(handler);
StaCommand command = new StaCommand(
@@ -223,7 +231,133 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal("TestMachine_001.TestAlarm001", handler.LastAckByNameTuple!.Value.Name);
Assert.Equal("Galaxy", handler.LastAckByNameTuple!.Value.Provider);
Assert.Equal("TestArea", handler.LastAckByNameTuple!.Value.Group);
Assert.Equal("alice", handler.LastAckOperatorName);
Assert.Equal("ack", handler.LastComment);
Assert.Equal("alice", handler.LastOperator!.UserName);
}
/// <summary>
/// Every ack outcome maps to its protocol status, carries the native
/// status, and never echoes the operator password.
/// </summary>
/// <param name="outcome">The writer outcome.</param>
/// <param name="expected">The expected protocol status.</param>
[Theory]
[InlineData(AlarmAckOutcome.Applied, ProtocolStatusCode.Ok)]
[InlineData(AlarmAckOutcome.Unconfirmed, ProtocolStatusCode.Ok)]
[InlineData(AlarmAckOutcome.AlarmNotFound, ProtocolStatusCode.InvalidRequest)]
[InlineData(AlarmAckOutcome.NotAckable, ProtocolStatusCode.InvalidRequest)]
[InlineData(AlarmAckOutcome.AuthenticationFailed, ProtocolStatusCode.MxaccessFailure)]
[InlineData(AlarmAckOutcome.WriteRejected, ProtocolStatusCode.MxaccessFailure)]
public void AcknowledgeAlarm_MapsOutcomeWithoutLeakingPassword(AlarmAckOutcome outcome, ProtocolStatusCode expected)
{
int native = outcome is AlarmAckOutcome.Applied or AlarmAckOutcome.Unconfirmed ? 0 : 1008;
FakeAlarmHandler handler = new FakeAlarmHandler
{
AckResult = AlarmAckResult.Create(outcome, native, "diagnostic for alice"),
};
MxCommandReply reply = Execute(handler, new MxCommand
{
Kind = MxCommandKind.AcknowledgeAlarm,
AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand
{
AlarmGuid = Guid.NewGuid().ToString(),
Comment = "checked",
OperatorUser = "alice",
OperatorDomain = "CORP",
OperatorPassword = "pw-secret",
},
});
AssertAckReply(reply, handler, expected, native);
}
/// <summary>The by-name ack maps outcomes identically and never echoes the operator password.</summary>
/// <param name="outcome">The writer outcome.</param>
/// <param name="expected">The expected protocol status.</param>
[Theory]
[InlineData(AlarmAckOutcome.Applied, ProtocolStatusCode.Ok)]
[InlineData(AlarmAckOutcome.Unconfirmed, ProtocolStatusCode.Ok)]
[InlineData(AlarmAckOutcome.AlarmNotFound, ProtocolStatusCode.InvalidRequest)]
[InlineData(AlarmAckOutcome.NotAckable, ProtocolStatusCode.InvalidRequest)]
[InlineData(AlarmAckOutcome.AuthenticationFailed, ProtocolStatusCode.MxaccessFailure)]
[InlineData(AlarmAckOutcome.WriteRejected, ProtocolStatusCode.MxaccessFailure)]
public void AcknowledgeAlarmByName_MapsOutcomeWithoutLeakingPassword(AlarmAckOutcome outcome, ProtocolStatusCode expected)
{
int native = outcome is AlarmAckOutcome.Applied or AlarmAckOutcome.Unconfirmed ? 0 : 1008;
FakeAlarmHandler handler = new FakeAlarmHandler
{
AckResult = AlarmAckResult.Create(outcome, native, "diagnostic for alice"),
};
MxCommandReply reply = Execute(handler, new MxCommand
{
Kind = MxCommandKind.AcknowledgeAlarmByName,
AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand
{
AlarmName = "TestMachine_001.TestAlarm001",
ProviderName = "Galaxy",
GroupName = "TestArea",
Comment = "checked",
OperatorUser = "alice",
OperatorDomain = "CORP",
OperatorPassword = "pw-secret",
},
});
AssertAckReply(reply, handler, expected, native);
}
/// <summary>The writer's OnWriteComplete statuses are copied onto the reply.</summary>
[Fact]
public void AcknowledgeAlarm_CopiesWriteStatusesIntoReply()
{
MxStatusProxy status = new MxStatusProxy { Category = MxStatusCategory.Ok, Success = 1 };
FakeAlarmHandler handler = new FakeAlarmHandler
{
AckResult = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty, new[] { status }),
};
MxCommandReply reply = Execute(handler, new MxCommand
{
Kind = MxCommandKind.AcknowledgeAlarm,
AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand { AlarmGuid = Guid.NewGuid().ToString(), OperatorUser = "alice" },
});
Assert.Equal(status, Assert.Single(reply.Statuses));
}
/// <summary>
/// An exception escaping the ack path (e.g. the writer's MXAccess
/// Register failing) maps to MxaccessFailure with only the exception type
/// and HRESULT — never the exception text, which is not under our control.
/// </summary>
[Fact]
public void AcknowledgeAlarmByName_WhenHandlerThrows_ReportsTypeAndHresultOnly()
{
FakeAlarmHandler handler = new FakeAlarmHandler
{
AcknowledgeException = new System.Runtime.InteropServices.COMException("echo pw-secret", unchecked((int)0x80004005)),
};
MxCommandReply reply = Execute(handler, new MxCommand
{
Kind = MxCommandKind.AcknowledgeAlarmByName,
AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand
{
AlarmName = "TestMachine_001.TestAlarm001",
ProviderName = "Galaxy",
OperatorUser = "alice",
OperatorPassword = "pw-secret",
},
});
Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
Assert.Equal(unchecked((int)0x80004005), reply.Hresult);
Assert.Contains("COMException", reply.DiagnosticMessage, StringComparison.Ordinal);
Assert.Contains("0x80004005", reply.DiagnosticMessage, StringComparison.Ordinal);
Assert.DoesNotContain("pw-secret", reply.DiagnosticMessage, StringComparison.Ordinal);
Assert.DoesNotContain("pw-secret", reply.ProtocolStatus.Message, StringComparison.Ordinal);
}
/// <summary>Verifies that empty alarm name returns invalid request.</summary>
@@ -393,7 +527,10 @@ public sealed class AlarmCommandExecutorTests
[Fact]
public void AcknowledgeAlarm_WhenHandlerThrows_ReturnsMxaccessFailure()
{
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeThrow = true };
FakeAlarmHandler handler = new FakeAlarmHandler
{
AcknowledgeException = new InvalidOperationException("simulated alarm-handler failure"),
};
MxAccessCommandExecutor executor = NewExecutor(handler);
StaCommand command = new StaCommand(
@@ -410,7 +547,25 @@ public sealed class AlarmCommandExecutorTests
MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
Assert.Contains("simulated", reply.DiagnosticMessage);
Assert.Contains("InvalidOperationException", reply.DiagnosticMessage, StringComparison.Ordinal);
Assert.DoesNotContain("simulated", reply.DiagnosticMessage, StringComparison.Ordinal);
}
private static MxCommandReply Execute(IAlarmCommandHandler handler, MxCommand command) =>
NewExecutor(handler).Execute(new StaCommand(SessionId, CorrelationId, command));
private static void AssertAckReply(MxCommandReply reply, FakeAlarmHandler handler, ProtocolStatusCode expected, int native)
{
Assert.Equal(expected, reply.ProtocolStatus.Code);
Assert.Equal(native, reply.Hresult);
Assert.Equal(native, reply.AcknowledgeAlarm.NativeStatus);
Assert.Equal("diagnostic for alice", reply.DiagnosticMessage);
Assert.Equal("checked", handler.LastComment);
Assert.Equal("alice", handler.LastOperator!.UserName);
Assert.Equal("CORP", handler.LastOperator.Domain);
Assert.Equal("pw-secret", handler.LastOperator.Password);
Assert.DoesNotContain("pw-secret", reply.DiagnosticMessage ?? string.Empty, StringComparison.Ordinal);
Assert.DoesNotContain("pw-secret", reply.ProtocolStatus.Message ?? string.Empty, StringComparison.Ordinal);
}
private static MxAccessCommandExecutor NewExecutor(IAlarmCommandHandler? alarmHandler)
@@ -442,14 +597,17 @@ public sealed class AlarmCommandExecutorTests
/// <summary>Gets the last acknowledge alarm GUID.</summary>
public Guid LastAckGuid { get; private set; }
/// <summary>Gets the last acknowledge operator name.</summary>
public string? LastAckOperatorName { get; private set; }
/// <summary>Gets the last acknowledge comment.</summary>
public string? LastComment { get; private set; }
/// <summary>Gets or sets the value returned by acknowledge.</summary>
public int AcknowledgeReturn { get; set; }
/// <summary>Gets the last acknowledge operator.</summary>
public AlarmAckOperator? LastOperator { get; private set; }
/// <summary>Gets or sets a value indicating whether acknowledge throws.</summary>
public bool AcknowledgeThrow { get; set; }
/// <summary>Gets or sets the result returned by both acknowledge methods.</summary>
public AlarmAckResult AckResult { get; set; } = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
/// <summary>Gets or sets an exception both acknowledge methods throw.</summary>
public Exception? AcknowledgeException { get; set; }
/// <summary>Gets or sets the query result snapshots.</summary>
public IReadOnlyList<ActiveAlarmSnapshot> QueryResult { get; set; } =
@@ -484,28 +642,32 @@ public sealed class AlarmCommandExecutorTests
}
/// <inheritdoc />
public int Acknowledge(
Guid alarmGuid, string comment, string operatorUser,
string operatorNode, string operatorDomain, string operatorFullName)
public AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator)
{
LastAckGuid = alarmGuid;
LastAckOperatorName = operatorUser;
if (AcknowledgeThrow)
LastComment = comment;
LastOperator = ackOperator;
if (AcknowledgeException is not null)
{
throw new InvalidOperationException("simulated alarm-handler failure");
throw AcknowledgeException;
}
return AcknowledgeReturn;
return AckResult;
}
/// <inheritdoc />
public int AcknowledgeByName(
string alarmName, string providerName, string groupName,
string comment, string operatorUser, string operatorNode,
string operatorDomain, string operatorFullName)
public AlarmAckResult AcknowledgeByName(
string alarmName, string providerName, string groupName, string comment, AlarmAckOperator ackOperator)
{
LastAckByNameTuple = (alarmName, providerName, groupName);
LastAckOperatorName = operatorUser;
return AcknowledgeReturn;
LastComment = comment;
LastOperator = ackOperator;
if (AcknowledgeException is not null)
{
throw AcknowledgeException;
}
return AckResult;
}
/// <summary>Gets the last acknowledge by name tuple.</summary>
@@ -96,22 +96,100 @@ public sealed class AlarmCommandHandlerTests
Assert.False(handler.IsSubscribed);
}
/// <summary>Verifies that acknowledge forwards to consumer with full operator identity when subscribed.</summary>
/// <summary>Verifies that acknowledge resolves via the consumer and writes through the injected writer as the operator.</summary>
[Fact]
public void Acknowledge_WhenSubscribed_ForwardsToConsumerWithFullOperatorIdentity()
public void Acknowledge_WhenSubscribed_ResolvesTargetAndWritesAsOperator()
{
FakeConsumer consumer = new FakeConsumer { AcknowledgeReturn = 0 };
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => consumer);
FakeConsumer consumer = new FakeConsumer { AckTarget = AlarmAckTarget.For("T.A.AckMsg") };
RecordingAckWriter writer = new RecordingAckWriter();
AlarmCommandHandler handler = NewHandlerWithWriter(() => consumer, writer);
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
Guid g = Guid.NewGuid();
int rc = handler.Acknowledge(g, "c", "u", "n", "d", "F");
AlarmAckOperator op = new AlarmAckOperator { UserName = "u", Domain = "d", Password = "p" };
AlarmAckResult result = handler.Acknowledge(g, "c", op);
Assert.Equal(0, rc);
Assert.Equal(g, consumer.LastAckGuid);
Assert.Equal("u", consumer.LastAckOperatorName);
Assert.Equal(AlarmAckOutcome.Applied, result.Outcome);
Assert.Equal(g, consumer.LastResolvedGuid);
Assert.Equal("T.A.AckMsg", writer.LastItemAddress);
Assert.Equal("c", writer.LastComment);
Assert.Same(op, writer.LastOperator);
}
/// <summary>Verifies acknowledge-by-name forwards the tuple and a null comment becomes empty.</summary>
[Fact]
public void AcknowledgeByName_WhenSubscribed_ForwardsTupleAndNormalizesComment()
{
FakeConsumer consumer = new FakeConsumer();
RecordingAckWriter writer = new RecordingAckWriter();
AlarmCommandHandler handler = NewHandlerWithWriter(() => consumer, writer);
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
handler.AcknowledgeByName("Tag1", "Galaxy", "Area", null!, new AlarmAckOperator { UserName = "u" });
Assert.Equal(("Tag1", "Galaxy", "Area"), consumer.LastResolvedName);
Assert.Equal(string.Empty, writer.LastComment);
}
/// <summary>Verifies a null operator is rejected.</summary>
[Fact]
public void Acknowledge_NullOperator_Throws()
{
AlarmCommandHandler handler = NewHandlerWithWriter(() => new FakeConsumer(), new RecordingAckWriter());
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
Assert.Throws<ArgumentNullException>(() => handler.Acknowledge(Guid.NewGuid(), "c", null!));
}
/// <summary>
/// The ack writer is created once, on first acknowledgement, survives an
/// unsubscribe/resubscribe cycle, and is disposed with the handler.
/// </summary>
[Fact]
public void AckWriter_CreatedLazilyOnce_AndDisposedWithHandler()
{
RecordingAckWriter writer = new RecordingAckWriter();
int created = 0;
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => new FakeConsumer(),
threadAffinityCheck: null,
comFactory: null,
standbyFactory: null,
ackWriterFactory: () =>
{
created++;
return writer;
});
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
Assert.Equal(0, created);
handler.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator());
handler.Unsubscribe();
Assert.False(writer.Disposed);
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
handler.AcknowledgeByName("a", "Galaxy", "g", "c", new AlarmAckOperator());
Assert.Equal(1, created);
handler.Dispose();
Assert.True(writer.Disposed);
}
/// <summary>
/// Without a COM factory or writer factory, subscribing still works and
/// the acknowledgement fails with a diagnosable InvalidOperationException.
/// </summary>
[Fact]
public void Acknowledge_WithoutComFactoryOrWriterFactory_ThrowsInvalidOperation()
{
AlarmCommandHandler handler = new AlarmCommandHandler(new MxAccessEventQueue(), () => new FakeConsumer());
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(
() => handler.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator()));
Assert.Contains("IMxAccessComObjectFactory", ex.Message);
}
/// <summary>Verifies that acknowledge throws invalid operation when called before subscribe.</summary>
@@ -123,7 +201,7 @@ public sealed class AlarmCommandHandlerTests
() => new FakeConsumer());
Assert.Throws<InvalidOperationException>(
() => handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
() => handler.Acknowledge(Guid.Empty, "", new AlarmAckOperator()));
}
/// <summary>Verifies that query active returns mapped proto snapshots when consumer has alarms.</summary>
@@ -266,7 +344,10 @@ public sealed class AlarmCommandHandlerTests
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => consumer,
() => guardInvocations++);
() => guardInvocations++,
comFactory: null,
standbyFactory: null,
ackWriterFactory: () => new RecordingAckWriter());
// Subscribe is the first call — guard must run before the consumer
// factory is invoked. We tally invocation counts after each call so
@@ -275,10 +356,10 @@ public sealed class AlarmCommandHandlerTests
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
Assert.Equal(1, guardInvocations);
handler.Acknowledge(Guid.NewGuid(), "c", "u", "n", "d", "F");
handler.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator { UserName = "u" });
Assert.Equal(2, guardInvocations);
handler.AcknowledgeByName("a", "p", "g", "c", "u", "n", "d", "F");
handler.AcknowledgeByName("a", "p", "g", "c", new AlarmAckOperator { UserName = "u" });
Assert.Equal(3, guardInvocations);
_ = handler.QueryActive(null, out _);
@@ -323,9 +404,9 @@ public sealed class AlarmCommandHandlerTests
// assert each independently with a fresh handler. Below we reuse
// the same throwing handler for the not-subscribed-yet entries:
Assert.Throws<InvalidOperationException>(
() => handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
() => handler.Acknowledge(Guid.Empty, "", new AlarmAckOperator()));
Assert.Throws<InvalidOperationException>(
() => handler.AcknowledgeByName("", "", "", "", "", "", "", ""));
() => handler.AcknowledgeByName("", "", "", "", new AlarmAckOperator()));
Assert.Throws<InvalidOperationException>(() => handler.QueryActive(null, out _));
Assert.Throws<InvalidOperationException>(() => handler.PollOnce());
Assert.Throws<InvalidOperationException>(
@@ -422,16 +503,17 @@ public sealed class AlarmCommandHandlerTests
[Fact]
public void Subscribe_AutoModeWithEmptyWatchListAndFallbackEnabled_BuildsReplaceableStandby()
{
FakeConsumer primary = new FakeConsumer { AcknowledgeReturn = 11 };
FakeConsumer initialStandby = new FakeConsumer { AcknowledgeReturn = 22 };
FakeConsumer replacement = new FakeConsumer { AcknowledgeReturn = 33 };
FakeConsumer primary = new FakeConsumer { AckTarget = AlarmAckTarget.For("11.AckMsg") };
FakeConsumer initialStandby = new FakeConsumer { AckTarget = AlarmAckTarget.For("22.AckMsg") };
FakeConsumer replacement = new FakeConsumer { AckTarget = AlarmAckTarget.For("33.AckMsg") };
int buildCount = 0;
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => primary,
threadAffinityCheck: null,
comFactory: null,
standbyFactory: _ => ++buildCount == 1 ? initialStandby : replacement);
standbyFactory: _ => ++buildCount == 1 ? initialStandby : replacement,
ackWriterFactory: () => new RecordingAckWriter());
handler.Subscribe(new SubscribeAlarmsCommand
{
@@ -443,7 +525,7 @@ public sealed class AlarmCommandHandlerTests
Assert.Equal(2, buildCount);
Assert.Equal(@"\\HOST\Galaxy!Area", replacement.LastSubscription);
Assert.True(initialStandby.Disposed);
Assert.Equal(11, handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
Assert.Equal("11.AckMsg", AckItemOf(handler));
}
/// <summary>A replacement is rejected when no fallback-capable subscription is active.</summary>
@@ -471,16 +553,17 @@ public sealed class AlarmCommandHandlerTests
[Fact]
public void UpdateWatchList_WhenReplacementSubscribeFails_PreservesOldStandbyAndPrimaryMode()
{
FakeConsumer primary = new FakeConsumer { AcknowledgeReturn = 11 };
FakeConsumer oldStandby = new FakeConsumer { AcknowledgeReturn = 22 };
FakeConsumer failedReplacement = new FakeConsumer { ThrowOnSubscribe = true, AcknowledgeReturn = 33 };
FakeConsumer primary = new FakeConsumer { AckTarget = AlarmAckTarget.For("11.AckMsg") };
FakeConsumer oldStandby = new FakeConsumer { AckTarget = AlarmAckTarget.For("22.AckMsg") };
FakeConsumer failedReplacement = new FakeConsumer { ThrowOnSubscribe = true, AckTarget = AlarmAckTarget.For("33.AckMsg") };
int buildCount = 0;
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => primary,
null,
null,
_ => ++buildCount == 1 ? oldStandby : failedReplacement);
_ => ++buildCount == 1 ? oldStandby : failedReplacement,
() => new RecordingAckWriter());
SubscribeAlarmsCommand subscribe = new SubscribeAlarmsCommand
{
SubscriptionExpression = @"\\HOST\Galaxy!Area",
@@ -494,23 +577,24 @@ public sealed class AlarmCommandHandlerTests
Assert.False(oldStandby.Disposed);
Assert.True(failedReplacement.Disposed);
Assert.Equal(11, handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
Assert.Equal("11.AckMsg", AckItemOf(handler));
}
/// <summary>A successful replacement while failed over keeps subtag mode active and delegates to the new standby.</summary>
[Fact]
public void UpdateWatchList_WhileSubtagActive_PreservesModeAndUsesReplacement()
{
FakeConsumer primary = new FakeConsumer { ThrowOnSubscribe = true, AcknowledgeReturn = 11 };
FakeConsumer oldStandby = new FakeConsumer { AcknowledgeReturn = 22 };
FakeConsumer replacement = new FakeConsumer { AcknowledgeReturn = 33 };
FakeConsumer primary = new FakeConsumer { ThrowOnSubscribe = true, AckTarget = AlarmAckTarget.For("11.AckMsg") };
FakeConsumer oldStandby = new FakeConsumer { AckTarget = AlarmAckTarget.For("22.AckMsg") };
FakeConsumer replacement = new FakeConsumer { AckTarget = AlarmAckTarget.For("33.AckMsg") };
int buildCount = 0;
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => primary,
null,
null,
_ => ++buildCount == 1 ? oldStandby : replacement);
_ => ++buildCount == 1 ? oldStandby : replacement,
() => new RecordingAckWriter());
SubscribeAlarmsCommand subscribe = new SubscribeAlarmsCommand
{
SubscriptionExpression = @"\\HOST\Galaxy!Area",
@@ -523,7 +607,7 @@ public sealed class AlarmCommandHandlerTests
handler.UpdateWatchList(new[] { NewTarget("Galaxy!Area.Tank02.Alarm") });
Assert.True(oldStandby.Disposed);
Assert.Equal(33, handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
Assert.Equal("33.AckMsg", AckItemOf(handler));
}
/// <summary>
@@ -567,6 +651,19 @@ public sealed class AlarmCommandHandlerTests
Assert.Equal(at, evt.OnAlarmProviderModeChanged.At.ToDateTime());
}
private static AlarmCommandHandler NewHandlerWithWriter(Func<IMxAccessAlarmConsumer> consumerFactory, IAlarmAckWriter writer) =>
new AlarmCommandHandler(
new MxAccessEventQueue(),
consumerFactory,
threadAffinityCheck: null,
comFactory: null,
standbyFactory: null,
ackWriterFactory: () => writer);
/// <summary>Acks through the handler and returns the item the active consumer resolved (the fake writer echoes it as the diagnostic).</summary>
private static string AckItemOf(AlarmCommandHandler handler) =>
handler.Acknowledge(Guid.Empty, string.Empty, new AlarmAckOperator()).Diagnostic;
private static MxAlarmSnapshotRecord NewRecord(string provider, string group, string tag)
{
return new MxAlarmSnapshotRecord
@@ -598,12 +695,12 @@ public sealed class AlarmCommandHandlerTests
/// <summary>Gets the last subscription request.</summary>
public string? LastSubscription { get; private set; }
/// <summary>Gets the last acknowledged alarm GUID.</summary>
public Guid LastAckGuid { get; private set; }
/// <summary>Gets the last acknowledged operator name.</summary>
public string? LastAckOperatorName { get; private set; }
/// <summary>Gets or sets the return value for acknowledge operations.</summary>
public int AcknowledgeReturn { get; set; }
/// <summary>Gets or sets the ack target both resolve methods return.</summary>
public AlarmAckTarget AckTarget { get; set; } = AlarmAckTarget.For("Fake.Alarm.AckMsg");
/// <summary>Gets the GUID from the last ResolveAckTarget call.</summary>
public Guid LastResolvedGuid { get; private set; }
/// <summary>Gets the (name, provider, group) from the last ResolveAckTargetByName call.</summary>
public (string Name, string Provider, string Group)? LastResolvedName { get; private set; }
/// <summary>Gets or sets the snapshot result to return.</summary>
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } =
Array.Empty<MxAlarmSnapshotRecord>();
@@ -623,29 +720,19 @@ public sealed class AlarmCommandHandlerTests
}
/// <inheritdoc />
public int AcknowledgeByGuid(
Guid alarmGuid, string ackComment, string ackOperatorName,
string ackOperatorNode, string ackOperatorDomain, string ackOperatorFullName)
public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
{
LastAckGuid = alarmGuid;
LastAckOperatorName = ackOperatorName;
return AcknowledgeReturn;
LastResolvedGuid = alarmGuid;
return AckTarget;
}
/// <inheritdoc />
public int AcknowledgeByName(
string alarmName, string providerName, string groupName,
string ackComment, string ackOperatorName, string ackOperatorNode,
string ackOperatorDomain, string ackOperatorFullName)
public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName)
{
LastAckByNameTuple = (alarmName, providerName, groupName);
LastAckOperatorName = ackOperatorName;
return AcknowledgeReturn;
LastResolvedName = (alarmName, providerName, groupName);
return AckTarget;
}
/// <summary>Gets the last acknowledge-by-name parameters.</summary>
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
/// <summary>Gets or sets the truncation verdict this consumer reports with its snapshot.</summary>
public bool SnapshotTruncated { get; set; }
@@ -671,4 +758,22 @@ public sealed class AlarmCommandHandlerTests
Disposed = true;
}
}
private sealed class RecordingAckWriter : IAlarmAckWriter
{
public string? LastItemAddress { get; private set; }
public string? LastComment { get; private set; }
public AlarmAckOperator? LastOperator { get; private set; }
public bool Disposed { get; private set; }
public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator)
{
LastItemAddress = itemAddress;
LastComment = comment;
LastOperator = ackOperator;
return AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, itemAddress);
}
public void Dispose() => Disposed = true;
}
}
@@ -24,7 +24,7 @@ public sealed class AlarmDispatcherTests
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter());
consumer.RaiseTransition(new MxAlarmTransitionEvent
{
@@ -56,7 +56,7 @@ public sealed class AlarmDispatcherTests
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter());
DateTime ts = new DateTime(2026, 5, 1, 17, 26, 14, 709, DateTimeKind.Utc);
consumer.RaiseTransition(new MxAlarmTransitionEvent
@@ -142,7 +142,7 @@ public sealed class AlarmDispatcherTests
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter());
consumer.RaiseTransition(new MxAlarmTransitionEvent
{
@@ -177,7 +177,7 @@ public sealed class AlarmDispatcherTests
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter());
consumer.RaiseTransition(new MxAlarmTransitionEvent
{
@@ -205,155 +205,140 @@ public sealed class AlarmDispatcherTests
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId);
SessionId,
new FakeAckWriter());
dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
Assert.Equal(@"\\HOST\Galaxy!Area1", consumer.LastSubscription);
}
/// <summary>Verifies that acknowledge calls are forwarded to the consumer with operator identity.</summary>
/// <summary>Verifies the dispatcher resolves the ack item via the consumer then writes it as the operator.</summary>
[Fact]
public void Acknowledge_WhenInvoked_ForwardsToConsumerWithFullOperatorIdentity()
public void Acknowledge_ResolvesTargetThenWritesWithOperator()
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
consumer.AcknowledgeReturn = 0;
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.For("T.A.AckMsg") };
FakeAckWriter writer = new FakeAckWriter();
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId);
consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
Guid guid = Guid.NewGuid();
int rc = dispatcher.Acknowledge(
guid, "Acked", "alice", "WS01", "CORP", "Alice Smith");
AlarmAckOperator op = new AlarmAckOperator { UserName = "alice", Password = "pw" };
Assert.Equal(0, rc);
Assert.Equal(guid, consumer.LastAckGuid);
Assert.Equal("Acked", consumer.LastAckComment);
Assert.Equal("alice", consumer.LastAckOperatorName);
Assert.Equal("WS01", consumer.LastAckOperatorNode);
Assert.Equal("CORP", consumer.LastAckOperatorDomain);
Assert.Equal("Alice Smith", consumer.LastAckOperatorFullName);
AlarmAckResult result = dispatcher.Acknowledge(guid, "checked", op);
Assert.Equal(AlarmAckOutcome.Applied, result.Outcome);
Assert.Equal(guid, consumer.LastResolvedGuid);
Assert.Equal("T.A.AckMsg", writer.LastItemAddress);
Assert.Equal("checked", writer.LastComment);
Assert.Same(op, writer.LastOperator);
}
/// <summary>Verifies that a managed ack client takes GUID acks with full operator identity.</summary>
/// <summary>Verifies an unresolved target short-circuits to its failure without calling the writer.</summary>
[Fact]
public void Acknowledge_WithManagedAckClient_RoutesGuidAckToManagedClient()
public void Acknowledge_WhenTargetNotFound_DoesNotWrite()
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = -1 };
FakeManagedAckClient managed = new FakeManagedAckClient { AcknowledgeReturn = 0 };
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.NotFound("gone") };
FakeAckWriter writer = new FakeAckWriter();
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId,
managed);
dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
Guid guid = Guid.NewGuid();
int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith");
AlarmAckResult result = dispatcher.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator { UserName = "alice" });
Assert.Equal(0, rc);
Assert.Equal(@"\\HOST\Galaxy!Area1", managed.LastSubscription);
Assert.Equal(guid, managed.LastAckGuid);
Assert.Equal("CORP", managed.LastAckOperatorDomain);
Assert.Equal("Alice Smith", managed.LastAckOperatorFullName);
Assert.Equal(Guid.Empty, consumer.LastAckGuid);
Assert.Equal(AlarmAckOutcome.AlarmNotFound, result.Outcome);
Assert.Equal("gone", result.Diagnostic);
Assert.Null(writer.LastItemAddress);
}
/// <summary>
/// Verifies that a failed managed-client lifecycle neither fails the alarm
/// subscription nor leaves acks routed to the broken client.
/// </summary>
/// <summary>Verifies acknowledge-by-name resolves with the full tuple and writes the resolved item.</summary>
[Fact]
public void Subscribe_WhenManagedAckClientFails_FallsBackToConsumerAck()
public void AcknowledgeByName_ResolvesTupleThenWritesWithOperator()
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = 0 };
FakeManagedAckClient managed = new FakeManagedAckClient { ThrowOnSubscribe = true };
FakeAlarmConsumer consumer = new FakeAlarmConsumer
{
AckTarget = AlarmAckTarget.For("TestMachine_001.TestAlarm001.AckMsg"),
};
FakeAckWriter writer = new FakeAckWriter();
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId,
managed);
consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
AlarmAckOperator op = new AlarmAckOperator { UserName = "alice", Domain = "CORP" };
dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
Guid guid = Guid.NewGuid();
int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith");
AlarmAckResult result = dispatcher.AcknowledgeByName(
"TestMachine_001.TestAlarm001", "Galaxy", "TestArea", "ack", op);
Assert.Equal(@"\\HOST\Galaxy!Area1", consumer.LastSubscription);
Assert.True(managed.Disposed);
Assert.Equal(0, rc);
Assert.Equal(guid, consumer.LastAckGuid);
Assert.Equal(Guid.Empty, managed.LastAckGuid);
Assert.Equal(AlarmAckOutcome.Applied, result.Outcome);
Assert.Equal(("TestMachine_001.TestAlarm001", "Galaxy", "TestArea"), consumer.LastResolvedName);
Assert.Equal("TestMachine_001.TestAlarm001.AckMsg", writer.LastItemAddress);
Assert.Equal("ack", writer.LastComment);
Assert.Same(op, writer.LastOperator);
}
/// <summary>Verifies a NotAckable by-name target short-circuits without calling the writer.</summary>
[Fact]
public void AcknowledgeByName_WhenNotAckable_DoesNotWrite()
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.NotAckable("intouch") };
FakeAckWriter writer = new FakeAckWriter();
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
AlarmAckResult result = dispatcher.AcknowledgeByName("Tag1", "InTouch", "G", "c", new AlarmAckOperator());
Assert.Equal(AlarmAckOutcome.NotAckable, result.Outcome);
Assert.Null(writer.LastItemAddress);
}
/// <summary>
/// Verifies that while the failover consumer serves the subtag standby,
/// GUID acks (synthetic GUIDs) stay on the consumer, not the managed client.
/// GUID acks resolve through the standby (its synthetic GUIDs).
/// </summary>
[Fact]
public void Acknowledge_WhenFailoverInSubtagMode_KeepsGuidAckOnConsumer()
public void Acknowledge_WhenFailoverInSubtagMode_ResolvesThroughStandby()
{
FakeAlarmConsumer primary = new FakeAlarmConsumer { ThrowOnSubscribe = true };
FakeAlarmConsumer standby = new FakeAlarmConsumer { AcknowledgeReturn = 0 };
FakeAlarmConsumer standby = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.For("Standby.AckMsg") };
FailoverAlarmConsumer failover = new FailoverAlarmConsumer(
primary, standby, new FailoverSettings(threshold: 1, probeIntervalSeconds: 0, stableProbes: 3));
FakeManagedAckClient managed = new FakeManagedAckClient { AcknowledgeReturn = -1 };
FakeAckWriter writer = new FakeAckWriter();
using AlarmDispatcher dispatcher = new AlarmDispatcher(
failover,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId,
managed);
writer);
dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
Assert.Equal(AlarmProviderMode.Subtag, failover.Mode);
Guid guid = Guid.NewGuid();
int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith");
dispatcher.Acknowledge(guid, "Acked", new AlarmAckOperator { UserName = "alice" });
Assert.Equal(0, rc);
Assert.Equal(guid, standby.LastAckGuid);
Assert.Equal(Guid.Empty, managed.LastAckGuid);
Assert.Equal(guid, standby.LastResolvedGuid);
Assert.Equal(Guid.Empty, primary.LastResolvedGuid);
Assert.Equal("Standby.AckMsg", writer.LastItemAddress);
}
/// <summary>Verifies that disposing the dispatcher disposes the managed ack client.</summary>
/// <summary>Verifies the dispatcher does not dispose the writer: the handler owns it.</summary>
[Fact]
public void Dispose_WithManagedAckClient_DisposesManagedClient()
public void Dispose_DoesNotDisposeAckWriter()
{
FakeManagedAckClient managed = new FakeManagedAckClient();
FakeAckWriter writer = new FakeAckWriter();
AlarmDispatcher dispatcher = new AlarmDispatcher(
new FakeAlarmConsumer(),
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId,
managed);
writer);
dispatcher.Dispose();
Assert.True(managed.Disposed);
Assert.False(writer.Disposed);
}
/// <summary>Verifies that acknowledge-by-name calls are forwarded to the consumer.</summary>
/// <summary>Verifies the constructor rejects a null ack writer.</summary>
[Fact]
public void AcknowledgeByName_WhenInvoked_ForwardsToConsumerWithFullTuple()
public void Constructor_NullAckWriter_Throws()
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = 0 };
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer,
Assert.Throws<ArgumentNullException>(() => new AlarmDispatcher(
new FakeAlarmConsumer(),
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId);
int rc = dispatcher.AcknowledgeByName(
alarmName: "TestMachine_001.TestAlarm001",
providerName: "Galaxy",
groupName: "TestArea",
ackComment: "ack",
ackOperatorName: "alice",
ackOperatorNode: "WS",
ackOperatorDomain: "CORP",
ackOperatorFullName: "Alice Smith");
Assert.Equal(0, rc);
Assert.NotNull(consumer.LastAckByNameTuple);
Assert.Equal("TestMachine_001.TestAlarm001", consumer.LastAckByNameTuple!.Value.Name);
Assert.Equal("Galaxy", consumer.LastAckByNameTuple!.Value.Provider);
Assert.Equal("TestArea", consumer.LastAckByNameTuple!.Value.Group);
SessionId,
null!));
}
/// <summary>Verifies that consumer alarm records are mapped to proto snapshots.</summary>
@@ -391,7 +376,8 @@ public sealed class AlarmDispatcherTests
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId);
SessionId,
new FakeAckWriter());
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out _);
Assert.Equal(2, snapshots.Count);
@@ -447,7 +433,8 @@ public sealed class AlarmDispatcherTests
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId);
SessionId,
new FakeAckWriter());
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out _);
Assert.Equal(2, snapshots.Count);
@@ -482,7 +469,8 @@ public sealed class AlarmDispatcherTests
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId);
SessionId,
new FakeAckWriter());
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated);
@@ -512,7 +500,8 @@ public sealed class AlarmDispatcherTests
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId);
SessionId,
new FakeAckWriter());
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated);
@@ -542,7 +531,7 @@ public sealed class AlarmDispatcherTests
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter());
dispatcher.Dispose();
@@ -569,20 +558,12 @@ public sealed class AlarmDispatcherTests
/// <summary>Gets the last subscription reference.</summary>
public string? LastSubscription { get; private set; }
/// <summary>Gets the GUID from the last acknowledge call.</summary>
public Guid LastAckGuid { get; private set; }
/// <summary>Gets the comment from the last acknowledge call.</summary>
public string? LastAckComment { get; private set; }
/// <summary>Gets the operator name from the last acknowledge call.</summary>
public string? LastAckOperatorName { get; private set; }
/// <summary>Gets the operator node from the last acknowledge call.</summary>
public string? LastAckOperatorNode { get; private set; }
/// <summary>Gets the operator domain from the last acknowledge call.</summary>
public string? LastAckOperatorDomain { get; private set; }
/// <summary>Gets the operator full name from the last acknowledge call.</summary>
public string? LastAckOperatorFullName { get; private set; }
/// <summary>Gets or sets the return code for acknowledge operations.</summary>
public int AcknowledgeReturn { get; set; }
/// <summary>Gets or sets the ack target both resolve methods return.</summary>
public AlarmAckTarget AckTarget { get; set; } = AlarmAckTarget.For("Fake.Alarm.AckMsg");
/// <summary>Gets the GUID from the last ResolveAckTarget call.</summary>
public Guid LastResolvedGuid { get; private set; }
/// <summary>Gets the (name, provider, group) from the last ResolveAckTargetByName call.</summary>
public (string Name, string Provider, string Group)? LastResolvedName { get; private set; }
/// <summary>Gets or sets the result collection for snapshot operations.</summary>
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } =
Array.Empty<MxAlarmSnapshotRecord>();
@@ -607,37 +588,19 @@ public sealed class AlarmDispatcherTests
}
/// <inheritdoc />
public int AcknowledgeByGuid(
Guid alarmGuid,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
{
LastAckGuid = alarmGuid;
LastAckComment = ackComment;
LastAckOperatorName = ackOperatorName;
LastAckOperatorNode = ackOperatorNode;
LastAckOperatorDomain = ackOperatorDomain;
LastAckOperatorFullName = ackOperatorFullName;
return AcknowledgeReturn;
LastResolvedGuid = alarmGuid;
return AckTarget;
}
/// <inheritdoc />
public int AcknowledgeByName(
string alarmName, string providerName, string groupName,
string ackComment, string ackOperatorName, string ackOperatorNode,
string ackOperatorDomain, string ackOperatorFullName)
public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName)
{
LastAckByNameTuple = (alarmName, providerName, groupName);
LastAckOperatorName = ackOperatorName;
return AcknowledgeReturn;
LastResolvedName = (alarmName, providerName, groupName);
return AckTarget;
}
/// <summary>Gets the last acknowledge-by-name tuple (alarm name, provider, group).</summary>
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
/// <summary>Gets or sets the truncation verdict this consumer reports with its snapshot.</summary>
public bool SnapshotTruncated { get; set; }
@@ -664,49 +627,22 @@ public sealed class AlarmDispatcherTests
}
}
private sealed class FakeManagedAckClient : IManagedAlarmAckClient
private sealed class FakeAckWriter : IAlarmAckWriter
{
/// <summary>Gets the last subscription expression.</summary>
public string? LastSubscription { get; private set; }
/// <summary>Gets the GUID from the last acknowledge call.</summary>
public Guid LastAckGuid { get; private set; }
/// <summary>Gets the operator domain from the last acknowledge call.</summary>
public string? LastAckOperatorDomain { get; private set; }
/// <summary>Gets the operator full name from the last acknowledge call.</summary>
public string? LastAckOperatorFullName { get; private set; }
/// <summary>Gets or sets the return code for acknowledge operations.</summary>
public int AcknowledgeReturn { get; set; }
/// <summary>Gets or sets a value indicating whether <see cref="Subscribe"/> throws.</summary>
public bool ThrowOnSubscribe { get; set; }
/// <summary>Gets a value indicating whether this instance has been disposed.</summary>
public string? LastItemAddress { get; private set; }
public string? LastComment { get; private set; }
public AlarmAckOperator? LastOperator { get; private set; }
public AlarmAckResult Result { get; set; } = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
public bool Disposed { get; private set; }
/// <inheritdoc />
public void Subscribe(string subscription)
public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator)
{
if (ThrowOnSubscribe) throw new InvalidOperationException("RegisterConsumer returned 5.");
LastSubscription = subscription;
LastItemAddress = itemAddress;
LastComment = comment;
LastOperator = ackOperator;
return Result;
}
/// <inheritdoc />
public int AcknowledgeByGuid(
Guid alarmGuid,
string comment,
string operatorName,
string operatorNode,
string operatorDomain,
string operatorFullName)
{
LastAckGuid = alarmGuid;
LastAckOperatorDomain = operatorDomain;
LastAckOperatorFullName = operatorFullName;
return AcknowledgeReturn;
}
/// <inheritdoc />
public void Dispose()
{
Disposed = true;
}
public void Dispose() => Disposed = true;
}
}
@@ -663,15 +663,13 @@ public sealed class MxAccessStaSessionTests
}
/// <inheritdoc />
public int Acknowledge(Guid alarmGuid, string comment, string operatorUser,
string operatorNode, string operatorDomain, string operatorFullName)
=> 0;
public AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator)
=> AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
/// <inheritdoc />
public int AcknowledgeByName(string alarmName, string providerName, string groupName,
string comment, string operatorUser, string operatorNode,
string operatorDomain, string operatorFullName)
=> 0;
public AlarmAckResult AcknowledgeByName(string alarmName, string providerName, string groupName,
string comment, AlarmAckOperator ackOperator)
=> AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
/// <inheritdoc />
public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(
@@ -1,229 +0,0 @@
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;
/// <summary>
/// Live dev-rig smoke test for the MANAGED GUID-ack path — the
/// <see cref="AaManagedAlarmAckConsumer"/> wired into
/// <see cref="AlarmDispatcher"/>. Where <c>AlarmsLiveSmokeTests</c> acks by
/// name through the wnwrap COM consumer, this acks by GUID through AVEVA's
/// modern <c>aaAlarmManagedClient.AlarmClient.AlarmAckByGUID</c> — the path
/// that carries operator domain + full name and is not <c>E_NOTIMPL</c>.
///
/// Flow: subscribe a wnwrap reader + a managed ack client to
/// <c>\\&lt;machine&gt;\Galaxy!DEV</c>; 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 <see cref="AlarmTransitionKind.Acknowledge"/>
/// 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 <c>Skip=null</c> on the dev rig with AVEVA +
/// aaAlarmManagedClient.dll present and the flip script running.
/// </summary>
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<string> log = new ConcurrentQueue<string>();
/// <summary>Initializes a new instance of the AlarmManagedGuidAckLiveSmokeTests class.</summary>
/// <param name="output">Test output helper for logging.</param>
public AlarmManagedGuidAckLiveSmokeTests(ITestOutputHelper output)
{
this.output = output;
}
/// <summary>Acks a live alarm by GUID through the managed client and verifies the acked transition.</summary>
[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}");
}
@@ -86,7 +86,12 @@ public sealed class AlarmsLiveSmokeTests
new WNWRAPCONSUMERLib.wwAlarmConsumerClass(),
maxAlarmsPerFetch: 1024);
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
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);
@@ -139,26 +144,23 @@ public sealed class AlarmsLiveSmokeTests
$"Captured reference '{raiseBody.AlarmFullReference}' did not parse as Provider!Group.Tag.");
Log($"Ack target: provider='{provider}' group='{group}' name='{alarmName}'");
// Try the ack with real Windows identity. AVEVA's AlarmAckByName
// may reject synthetic operator strings; using the current process
// identity gives the alarm-history a recognizable principal.
string realUser = Environment.UserName;
string realNode = Environment.MachineName;
string realDomain = Environment.UserDomainName ?? string.Empty;
Log($"Ack identity: user='{realUser}' node='{realNode}' domain='{realDomain}'");
// 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}");
int rc = dispatcher.AcknowledgeByName(
alarmName: alarmName,
providerName: provider,
groupName: group,
ackComment: "alarms-live-smoke ack",
ackOperatorName: realUser,
ackOperatorNode: realNode,
ackOperatorDomain: realDomain,
ackOperatorFullName: realUser);
Log($"AcknowledgeByName(real identity) -> rc={rc}");
AlarmAckResult result = dispatcher.AcknowledgeByName(
alarmName, provider, group, "alarms-live-smoke ack", ackOperator);
Log($"AcknowledgeByName -> outcome={result.Outcome} native={result.NativeStatus} diag='{result.Diagnostic}'");
Assert.Equal(0, rc);
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