using System; using System.Collections.Generic; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Worker.MxAccess; using ZB.MOM.WW.MxGateway.Worker.Sta; using ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport; namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; /// /// Verifies that the four new alarm values /// route through to a fake /// and that the resulting /// carries the expected payload. /// /// The data-side is constructed via a /// no-op factory because the executor only touches it for non-alarm /// command kinds — alarm dispatch never reaches the data session. /// public sealed class AlarmCommandExecutorTests { private const string SessionId = "S"; private const string CorrelationId = "C"; /// Verifies that the handler routes alarm subscriptions and returns ok. [Fact] public void SubscribeAlarms_WithHandler_RoutesToHandlerAndReturnsOk() { FakeAlarmHandler handler = new FakeAlarmHandler(); MxAccessCommandExecutor executor = NewExecutor(handler); StaCommand command = new StaCommand( SessionId, CorrelationId, new MxCommand { Kind = MxCommandKind.SubscribeAlarms, SubscribeAlarms = new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!Area", }, }); MxCommandReply reply = executor.Execute(command); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.Equal(@"\\HOST\Galaxy!Area", handler.LastSubscription); Assert.Equal(SessionId, handler.LastSessionId); } /// Verifies that subscription without handler returns invalid request. [Fact] public void SubscribeAlarms_WithoutHandler_ReturnsInvalidRequest() { MxAccessCommandExecutor executor = NewExecutor(alarmHandler: null); StaCommand command = new StaCommand( SessionId, CorrelationId, new MxCommand { Kind = MxCommandKind.SubscribeAlarms, SubscribeAlarms = new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!Area", }, }); MxCommandReply reply = executor.Execute(command); Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); } /// Verifies that empty subscription expression returns invalid request. [Fact] public void SubscribeAlarms_WithEmptyExpression_ReturnsInvalidRequest() { MxAccessCommandExecutor executor = NewExecutor(new FakeAlarmHandler()); StaCommand command = new StaCommand( SessionId, CorrelationId, new MxCommand { Kind = MxCommandKind.SubscribeAlarms, SubscribeAlarms = new SubscribeAlarmsCommand { SubscriptionExpression = " ", }, }); MxCommandReply reply = executor.Execute(command); Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); } /// Verifies that a live watch-list replacement routes to the alarm handler on the executor thread. [Fact] public void UpdateAlarmWatchList_WithHandler_RoutesToHandlerAndReturnsOk() { FakeAlarmHandler handler = new FakeAlarmHandler(); MxAccessCommandExecutor executor = NewExecutor(handler); var target = new AlarmSubtagTarget { AlarmFullReference = "Galaxy!Area.Tank01.Alarm" }; MxCommandReply reply = executor.Execute(new StaCommand( SessionId, CorrelationId, new MxCommand { Kind = MxCommandKind.UpdateAlarmWatchList, UpdateAlarmWatchList = new UpdateAlarmWatchListCommand { WatchList = { target } }, })); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.Equal(target, Assert.Single(handler.LastWatchList!)); } /// Verifies that acknowledge routes native status into hresult and payload. [Fact] public void AcknowledgeAlarm_WithHandler_RoutesNativeStatusIntoHresultAndPayload() { FakeAlarmHandler handler = new FakeAlarmHandler(); MxAccessCommandExecutor executor = NewExecutor(handler); Guid g = Guid.NewGuid(); StaCommand command = new StaCommand( SessionId, CorrelationId, new MxCommand { Kind = MxCommandKind.AcknowledgeAlarm, AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand { AlarmGuid = g.ToString(), Comment = "ack", OperatorUser = "alice", OperatorNode = "WS", OperatorDomain = "CORP", OperatorFullName = "Alice S", }, }); MxCommandReply reply = executor.Execute(command); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.Equal(0, reply.Hresult); Assert.NotNull(reply.AcknowledgeAlarm); Assert.Equal(0, reply.AcknowledgeAlarm.NativeStatus); Assert.Equal(g, handler.LastAckGuid); Assert.Equal("ack", handler.LastComment); Assert.Equal("alice", handler.LastOperator!.UserName); Assert.Equal("CORP", handler.LastOperator.Domain); Assert.Equal(string.Empty, handler.LastOperator.Password); } /// Verifies that invalid alarm GUID returns invalid request. [Fact] public void AcknowledgeAlarm_WithInvalidGuid_ReturnsInvalidRequest() { MxAccessCommandExecutor executor = NewExecutor(new FakeAlarmHandler()); StaCommand command = new StaCommand( SessionId, CorrelationId, new MxCommand { Kind = MxCommandKind.AcknowledgeAlarm, AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand { AlarmGuid = "not-a-guid", }, }); MxCommandReply reply = executor.Execute(command); Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); } /// Verifies that a rejected write carries its native status and diagnostic. [Fact] public void AcknowledgeAlarm_WithNonzeroNativeStatus_CarriesDiagnostic() { FakeAlarmHandler handler = new FakeAlarmHandler { AckResult = AlarmAckResult.Create(AlarmAckOutcome.WriteRejected, -123, "AckMsg write rejected: detail -123."), }; MxAccessCommandExecutor executor = NewExecutor(handler); StaCommand command = new StaCommand( SessionId, CorrelationId, new MxCommand { Kind = MxCommandKind.AcknowledgeAlarm, AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand { AlarmGuid = Guid.NewGuid().ToString(), }, }); 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); } /// Verifies that acknowledge by name routes tuple to handler. [Fact] public void AcknowledgeAlarmByName_WithHandler_RoutesTupleToHandler() { FakeAlarmHandler handler = new FakeAlarmHandler(); MxAccessCommandExecutor executor = NewExecutor(handler); StaCommand command = new StaCommand( SessionId, CorrelationId, new MxCommand { Kind = MxCommandKind.AcknowledgeAlarmByName, AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand { AlarmName = "TestMachine_001.TestAlarm001", ProviderName = "Galaxy", GroupName = "TestArea", Comment = "ack", OperatorUser = "alice", }, }); MxCommandReply reply = executor.Execute(command); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.NotNull(reply.AcknowledgeAlarm); Assert.Equal(0, reply.AcknowledgeAlarm.NativeStatus); Assert.NotNull(handler.LastAckByNameTuple); 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("ack", handler.LastComment); Assert.Equal("alice", handler.LastOperator!.UserName); } /// /// Every ack outcome maps to its protocol status, carries the native /// status, and never echoes the operator password. /// /// The writer outcome. /// The expected protocol status. [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); } /// The by-name ack maps outcomes identically and never echoes the operator password. /// The writer outcome. /// The expected protocol status. [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); } /// The writer's OnWriteComplete statuses are copied onto the reply. [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)); } /// /// 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. /// [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); } /// Verifies that empty alarm name returns invalid request. [Fact] public void AcknowledgeAlarmByName_WithEmptyName_ReturnsInvalidRequest() { MxAccessCommandExecutor executor = NewExecutor(new FakeAlarmHandler()); StaCommand command = new StaCommand( SessionId, CorrelationId, new MxCommand { Kind = MxCommandKind.AcknowledgeAlarmByName, AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand { AlarmName = " ", ProviderName = "Galaxy", GroupName = "TestArea", }, }); MxCommandReply reply = executor.Execute(command); Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); } /// Verifies that query active alarms returns payload with snapshots. [Fact] public void QueryActiveAlarms_WithHandler_ReturnsPayloadWithSnapshots() { FakeAlarmHandler handler = new FakeAlarmHandler { QueryResult = new[] { new ActiveAlarmSnapshot { AlarmFullReference = "Galaxy!A.T1" }, new ActiveAlarmSnapshot { AlarmFullReference = "Galaxy!A.T2" }, }, }; MxAccessCommandExecutor executor = NewExecutor(handler); StaCommand command = new StaCommand( SessionId, CorrelationId, new MxCommand { Kind = MxCommandKind.QueryActiveAlarms, QueryActiveAlarmsCommand = new QueryActiveAlarmsCommand { AlarmFilterPrefix = "Galaxy!A", }, }); MxCommandReply reply = executor.Execute(command); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.NotNull(reply.QueryActiveAlarms); Assert.Equal(2, reply.QueryActiveAlarms.Snapshots.Count); Assert.Equal("Galaxy!A", handler.LastFilterPrefix); } /// /// The reply payload's SnapshotTruncated comes from the handler's /// verdict. This is the last hop before the IPC frame; if the executor /// dropped it, a filtered query returning no records would carry no /// completeness caveat at all. /// /// The verdict the fake handler reports. [Theory] [InlineData(true)] [InlineData(false)] public void QueryActiveAlarms_StampsSnapshotTruncatedFromHandler(bool handlerReportsTruncated) { FakeAlarmHandler handler = new FakeAlarmHandler { SnapshotTruncated = handlerReportsTruncated, QueryResult = new[] { new ActiveAlarmSnapshot { AlarmFullReference = "Galaxy!A.T1" }, }, }; MxAccessCommandExecutor executor = NewExecutor(handler); StaCommand command = new StaCommand( SessionId, CorrelationId, new MxCommand { Kind = MxCommandKind.QueryActiveAlarms, QueryActiveAlarmsCommand = new QueryActiveAlarmsCommand(), }); MxCommandReply reply = executor.Execute(command); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.NotNull(reply.QueryActiveAlarms); Assert.Equal(handlerReportsTruncated, reply.QueryActiveAlarms.SnapshotTruncated); } /// /// A truncated fetch whose records all filtered out still reports the /// caveat on the payload — the case the per-record flag cannot cover. /// [Fact] public void QueryActiveAlarms_WithTruncatedFetchAndNoRecords_StillReportsTruncation() { FakeAlarmHandler handler = new FakeAlarmHandler { SnapshotTruncated = true, QueryResult = Array.Empty(), }; MxAccessCommandExecutor executor = NewExecutor(handler); StaCommand command = new StaCommand( SessionId, CorrelationId, new MxCommand { Kind = MxCommandKind.QueryActiveAlarms, QueryActiveAlarmsCommand = new QueryActiveAlarmsCommand(), }); MxCommandReply reply = executor.Execute(command); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.Empty(reply.QueryActiveAlarms.Snapshots); Assert.True(reply.QueryActiveAlarms.SnapshotTruncated); } /// Verifies that unsubscribe routes to handler. [Fact] public void UnsubscribeAlarms_WithHandler_RoutesToHandler() { FakeAlarmHandler handler = new FakeAlarmHandler(); MxAccessCommandExecutor executor = NewExecutor(handler); StaCommand command = new StaCommand( SessionId, CorrelationId, new MxCommand { Kind = MxCommandKind.UnsubscribeAlarms, UnsubscribeAlarms = new UnsubscribeAlarmsCommand(), }); MxCommandReply reply = executor.Execute(command); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); Assert.True(handler.UnsubscribeCalled); } /// Verifies that unsubscribe without handler is an ok noop. [Fact] public void UnsubscribeAlarms_WithoutHandler_IsOkNoop() { MxAccessCommandExecutor executor = NewExecutor(alarmHandler: null); StaCommand command = new StaCommand( SessionId, CorrelationId, new MxCommand { Kind = MxCommandKind.UnsubscribeAlarms, UnsubscribeAlarms = new UnsubscribeAlarmsCommand(), }); MxCommandReply reply = executor.Execute(command); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); } /// Verifies that handler exception returns MXAccess failure status. [Fact] public void AcknowledgeAlarm_WhenHandlerThrows_ReturnsMxaccessFailure() { FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeException = new InvalidOperationException("simulated alarm-handler failure"), }; MxAccessCommandExecutor executor = NewExecutor(handler); StaCommand command = new StaCommand( SessionId, CorrelationId, new MxCommand { Kind = MxCommandKind.AcknowledgeAlarm, AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand { AlarmGuid = Guid.NewGuid().ToString(), }, }); MxCommandReply reply = executor.Execute(command); Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code); 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) { // Construct an executor with a no-op data session — we only exercise // the alarm switch arms, which never touch the data session. The // session is built through the internal MxAccessSession.CreateForTesting // factory (exposed via [assembly: InternalsVisibleTo("ZB.MOM.WW.MxGateway.Worker.Tests")] // on ZB.MOM.WW.MxGateway.Worker), so no reflection is needed. return new MxAccessCommandExecutor( session: MxAccessSession.CreateForTesting( mxAccessServer: new NoopMxAccessServer(), eventSink: new NoopEventSink()), variantConverter: new ZB.MOM.WW.MxGateway.Worker.Conversion.VariantConverter(), alarmCommandHandler: alarmHandler); } private sealed class FakeAlarmHandler : IAlarmCommandHandler { /// Gets the last subscription expression. public string? LastSubscription { get; private set; } /// Gets the last session ID. public string? LastSessionId { get; private set; } /// Gets a value indicating whether unsubscribe was called. public bool UnsubscribeCalled { get; private set; } /// Gets the last acknowledge alarm GUID. public Guid LastAckGuid { get; private set; } /// Gets the last acknowledge comment. public string? LastComment { get; private set; } /// Gets the last acknowledge operator. public AlarmAckOperator? LastOperator { get; private set; } /// Gets or sets the result returned by both acknowledge methods. public AlarmAckResult AckResult { get; set; } = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty); /// Gets or sets an exception both acknowledge methods throw. public Exception? AcknowledgeException { get; set; } /// Gets or sets the query result snapshots. public IReadOnlyList QueryResult { get; set; } = Array.Empty(); /// Gets the last alarm filter prefix. public string? LastFilterPrefix { get; private set; } /// Gets or sets the truncation verdict this handler reports with its query result. public bool SnapshotTruncated { get; set; } /// public void Subscribe(SubscribeAlarmsCommand command, string sessionId) { LastSubscription = command.SubscriptionExpression; LastSessionId = sessionId; } /// Gets the most recent replacement watch list. public IReadOnlyList? LastWatchList { get; private set; } /// public void UpdateWatchList(IReadOnlyList watchList) { LastWatchList = watchList; } /// public void Unsubscribe() { UnsubscribeCalled = true; } /// public AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator) { LastAckGuid = alarmGuid; LastComment = comment; LastOperator = ackOperator; if (AcknowledgeException is not null) { throw AcknowledgeException; } return AckResult; } /// public AlarmAckResult AcknowledgeByName( string alarmName, string providerName, string groupName, string comment, AlarmAckOperator ackOperator) { LastAckByNameTuple = (alarmName, providerName, groupName); LastComment = comment; LastOperator = ackOperator; if (AcknowledgeException is not null) { throw AcknowledgeException; } return AckResult; } /// Gets the last acknowledge by name tuple. public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; } /// public IReadOnlyList QueryActive( string? alarmFilterPrefix, out bool snapshotTruncated) { LastFilterPrefix = alarmFilterPrefix; snapshotTruncated = SnapshotTruncated; return QueryResult; } /// Gets the number of poll calls. public int PollCount { get; private set; } /// public void PollOnce() { PollCount++; } /// public void Dispose() { } } }