Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs
T
Joseph Doherty 76db822df3
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Failing after 56s
ci / java (push) Failing after 2m17s
ci / portable (push) Failing after 2m23s
feat(worker): acknowledge alarms via authenticated AckMsg write
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:21:55 -04:00

185 lines
7.2 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using Google.Protobuf.Collections;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
public sealed class AlarmAckWriterTests
{
private static AlarmAckOperator Alice() =>
new AlarmAckOperator { UserName = "alice", Domain = "CORP", Password = "pw-secret" };
private static RepeatedField<MxStatusProxy> Rows(MxStatusCategory category, int detail) =>
new RepeatedField<MxStatusProxy> { new MxStatusProxy { Success = category == MxStatusCategory.Ok ? 1 : 0, Category = category, Detail = detail } };
[Fact]
public void Acknowledge_AuthenticatesThenWritesCommentWithOperatorUserId()
{
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.Ok, 0));
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway");
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());
Assert.Equal(AlarmAckOutcome.Applied, result.Outcome);
Assert.Equal(0, result.NativeStatus);
Assert.Equal(new[] { "Register:MxGateway", "AuthenticateUser:CORP\\alice", "AddItem:T.A.AckMsg", "Advise", "Write:checked:5" }, server.Operations);
Assert.Equal("pw-secret", server.LastPassword);
}
[Fact]
public void Acknowledge_WhenWriteRejected_ReturnsDetailAndStatuses()
{
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.SecurityError, 1008));
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway");
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());
Assert.Equal(AlarmAckOutcome.WriteRejected, result.Outcome);
Assert.Equal(1008, result.NativeStatus);
Assert.Single(result.Statuses);
}
[Fact]
public void Acknowledge_WhenNoCompletion_ReturnsUnconfirmed()
{
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(50), "MxGateway");
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());
Assert.Equal(AlarmAckOutcome.Unconfirmed, result.Outcome);
Assert.Equal(0, result.NativeStatus);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void Acknowledge_WhenAuthenticationFails_DoesNotWriteOrLeakPassword(bool throwComException)
{
RecordingAckServer server = new RecordingAckServer
{
AuthenticateResult = 0,
AuthenticateException = throwComException ? new COMException("bad credentials", unchecked((int)0x80070005)) : null,
};
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(50), "MxGateway");
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());
Assert.Equal(AlarmAckOutcome.AuthenticationFailed, result.Outcome);
Assert.NotEqual(0, result.NativeStatus);
Assert.DoesNotContain(server.Operations, op => op.StartsWith("Write", StringComparison.Ordinal));
Assert.DoesNotContain("pw-secret", result.Diagnostic, StringComparison.Ordinal);
Assert.DoesNotContain(server.Operations, op => op.Contains("pw-secret"));
}
[Fact]
public void Acknowledge_Twice_ReusesItemHandle()
{
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
writer.Acknowledge("T.A.AckMsg", "one", Alice());
writer.Acknowledge("T.A.AckMsg", "two", Alice());
Assert.Equal(1, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal)));
Assert.Equal(1, server.Operations.Count(op => op == "Advise"));
}
[Fact]
public void AlarmAckOperator_ToString_OmitsPassword()
{
Assert.DoesNotContain("pw-secret", Alice().ToString(), StringComparison.Ordinal);
}
}
internal sealed class RecordingAckServer : IMxAccessServer
{
private int nextItemHandle = 100;
public List<string> Operations { get; } = new();
public int AuthenticateResult { get; set; }
public Exception? AuthenticateException { get; set; }
public string? LastPassword { get; private set; }
public Action<int, int>? OnWrite { get; set; }
public int Register(string clientName)
{
Operations.Add("Register:" + clientName);
return 7;
}
public void Unregister(int serverHandle) => Operations.Add("Unregister");
public int AddItem(int serverHandle, string itemDefinition)
{
Operations.Add("AddItem:" + itemDefinition);
return nextItemHandle++;
}
public int AddItem2(int serverHandle, string itemDefinition, string itemContext) => AddItem(serverHandle, itemDefinition);
public void RemoveItem(int serverHandle, int itemHandle) => Operations.Add("RemoveItem");
public void Advise(int serverHandle, int itemHandle) => Operations.Add("Advise");
public void UnAdvise(int serverHandle, int itemHandle) => Operations.Add("UnAdvise");
public void AdviseSupervisory(int serverHandle, int itemHandle)
{
}
public void Write(int serverHandle, int itemHandle, object? value, int userId)
{
Operations.Add("Write:" + value + ":" + userId);
OnWrite?.Invoke(serverHandle, itemHandle);
}
public void Write2(int serverHandle, int itemHandle, object? value, object? timestamp, int userId)
{
}
public void WriteSecured(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value)
{
}
public void WriteSecured2(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value, object? timestamp)
{
}
public object Suspend(int serverHandle, int itemHandle) => new object();
public object Activate(int serverHandle, int itemHandle) => new object();
public int AuthenticateUser(int serverHandle, string verifyUser, string verifyUserPassword)
{
Operations.Add("AuthenticateUser:" + verifyUser);
LastPassword = verifyUserPassword;
if (AuthenticateException is not null)
{
throw AuthenticateException;
}
return AuthenticateResult;
}
public int ArchestrAUserToId(int serverHandle, string userIdGuid) => 0;
public int AddBufferedItem(int serverHandle, string itemDefinition, string itemContext) => AddItem(serverHandle, itemDefinition);
public void SetBufferedUpdateInterval(int serverHandle, int updateIntervalMilliseconds)
{
}
}