feat(worker): route alarm acks through the operator-authenticated AckMsg writer
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:
@@ -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>\\<machine>\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
|
||||
|
||||
Reference in New Issue
Block a user