463748e395
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m24s
ci / java (push) Successful in 2m32s
ci / nightly-windev (pull_request) Has been skipped
ci / java (pull_request) Successful in 2m38s
ci / portable (push) Failing after 2m52s
ci / portable (pull_request) Failing after 2m53s
ci / windows-x86 (pull_request) Successful in 8m8s
Address review of the #164-#166 fixes: - The HRESULT check could not tell interop from programming errors (a managed ArgumentException already defaults to 0x80070057). Wrap each MXAccess call (AuthenticateUser, AddItem, Advise, Write) in a narrow filter that converts an interop failure to a private marker exception, and catch only that marker, so exceptions from the writer's own code (argument checks, caching, the pump) propagate. - A late OnWriteComplete for a dropped handle recreated the cache entry that DropItem removed. The writer now records completions only for item handles it holds on its live registration. - The item-handle-reuse probe now asserts that handles within one registration strictly increase, so a rerun is a regression check. Refs #164, #165, #166 Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
535 lines
25 KiB
C#
535 lines
25 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 };
|
|
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");
|
|
|
|
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 Acknowledge_WhenAdviseThrows_RejectsCleansUpAndReAddsNextTime()
|
|
{
|
|
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5, AdviseException = new COMException("x", unchecked((int)0x80004005)) };
|
|
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
|
|
|
|
AlarmAckResult first = writer.Acknowledge("T.A.AckMsg", "one", Alice());
|
|
server.AdviseException = null;
|
|
writer.Acknowledge("T.A.AckMsg", "two", Alice());
|
|
|
|
Assert.Equal(AlarmAckOutcome.WriteRejected, first.Outcome);
|
|
Assert.Equal(unchecked((int)0x80004005), first.NativeStatus);
|
|
Assert.DoesNotContain("pw-secret", first.Diagnostic, StringComparison.Ordinal);
|
|
Assert.Contains("RemoveItem", server.Operations);
|
|
Assert.Equal(2, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal)));
|
|
}
|
|
|
|
[Fact]
|
|
public void Acknowledge_WhenCachedWriteThrows_RejectsAndReAddsNextTime()
|
|
{
|
|
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");
|
|
writer.Acknowledge("T.A.AckMsg", "one", Alice());
|
|
server.WriteException = new COMException("x", unchecked((int)0x80004005));
|
|
|
|
AlarmAckResult failed = writer.Acknowledge("T.A.AckMsg", "two", Alice());
|
|
server.WriteException = null;
|
|
writer.Acknowledge("T.A.AckMsg", "three", Alice());
|
|
|
|
Assert.Equal(AlarmAckOutcome.WriteRejected, failed.Outcome);
|
|
Assert.Equal(2, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal)));
|
|
}
|
|
|
|
[Fact]
|
|
public void Acknowledge_WhenWriteDisconnected_TearsDownServer()
|
|
{
|
|
// The test seam has no COM factory, so re-registration itself is not
|
|
// exercised here; this verifies the teardown that makes it possible.
|
|
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5, WriteException = new COMException("x", unchecked((int)0x80010108)) };
|
|
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
|
|
|
|
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "one", Alice());
|
|
|
|
Assert.Equal(AlarmAckOutcome.WriteRejected, result.Outcome);
|
|
Assert.Equal(unchecked((int)0x80010108), result.NativeStatus);
|
|
Assert.Contains("Unregister", server.Operations);
|
|
}
|
|
|
|
[Fact]
|
|
public void Acknowledge_WhenAuthenticateUserDisconnected_TearsDownAndRejects()
|
|
{
|
|
RecordingAckServer server = new RecordingAckServer
|
|
{
|
|
AuthenticateResult = 5,
|
|
AuthenticateException = new COMException("gone pw-secret", unchecked((int)0x80010108)),
|
|
};
|
|
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
|
|
|
|
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "one", Alice());
|
|
|
|
Assert.Equal(AlarmAckOutcome.WriteRejected, result.Outcome);
|
|
Assert.Equal(unchecked((int)0x80010108), result.NativeStatus);
|
|
Assert.Contains("AuthenticateUser", result.Diagnostic, StringComparison.Ordinal);
|
|
Assert.Contains("0x80010108", result.Diagnostic, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("pw-secret", result.Diagnostic, StringComparison.Ordinal);
|
|
Assert.Contains("Unregister", server.Operations);
|
|
Assert.DoesNotContain(server.Operations, op => op.StartsWith("AddItem", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void Acknowledge_WhenAuthenticateUserThrowsArgumentException_ReturnsAuthenticationFailed()
|
|
{
|
|
// Live MXAccess raises E_INVALIDARG for a bad user or wrong password, which the
|
|
// interop surfaces as ArgumentException rather than COMException.
|
|
RecordingAckServer server = new RecordingAckServer
|
|
{
|
|
AuthenticateResult = 5,
|
|
AuthenticateException = new ArgumentException("Value does not fall within the expected range. pw-secret"),
|
|
};
|
|
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
|
|
|
|
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "one", Alice());
|
|
|
|
Assert.Equal(AlarmAckOutcome.AuthenticationFailed, result.Outcome);
|
|
Assert.Equal(unchecked((int)0x80070057), result.NativeStatus);
|
|
Assert.DoesNotContain("pw-secret", result.Diagnostic, StringComparison.Ordinal);
|
|
Assert.DoesNotContain(server.Operations, op => op.StartsWith("Write", StringComparison.Ordinal));
|
|
Assert.DoesNotContain("Unregister", server.Operations);
|
|
}
|
|
|
|
[Fact]
|
|
public void Acknowledge_WhenAuthenticateUserThrowsArgumentNullException_Propagates()
|
|
{
|
|
// A programming error is a bug, not an operator authentication failure.
|
|
RecordingAckServer server = new RecordingAckServer
|
|
{
|
|
AuthenticateResult = 5,
|
|
AuthenticateException = new ArgumentNullException("verifyUser"),
|
|
};
|
|
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
|
|
|
|
Assert.Throws<ArgumentNullException>(() => writer.Acknowledge("T.A.AckMsg", "one", Alice()));
|
|
}
|
|
|
|
[Fact]
|
|
public void Acknowledge_WhenOwnCodeOutsideComCallThrowsArgumentException_Propagates()
|
|
{
|
|
// The pump runs between Advise and Write. An ArgumentException from it carries
|
|
// the same default HRESULT (0x80070057) as an interop E_INVALIDARG, so only
|
|
// catching at the COM call itself keeps it from being reported as WriteRejected.
|
|
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
|
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => throw new ArgumentException("bug"), TimeSpan.FromMilliseconds(10), "MxGateway");
|
|
|
|
ArgumentException thrown = Assert.Throws<ArgumentException>(() => writer.Acknowledge("T.A.AckMsg", "one", Alice()));
|
|
Assert.Equal("bug", thrown.Message);
|
|
Assert.DoesNotContain(server.Operations, op => op.StartsWith("Write", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void Acknowledge_WhenWriteThrowsArgumentOutOfRangeException_Propagates()
|
|
{
|
|
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5, WriteException = new ArgumentOutOfRangeException("value") };
|
|
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
|
|
|
|
Assert.Throws<ArgumentOutOfRangeException>(() => writer.Acknowledge("T.A.AckMsg", "one", Alice()));
|
|
}
|
|
|
|
[Fact]
|
|
public void Acknowledge_WhenOperatorUserNameIsNull_ThrowsBeforeAuthenticating()
|
|
{
|
|
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
|
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
|
|
|
|
Assert.Throws<ArgumentException>(() => writer.Acknowledge("T.A.AckMsg", "one", new AlarmAckOperator { UserName = null! }));
|
|
Assert.DoesNotContain(server.Operations, op => op.StartsWith("AuthenticateUser", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void Acknowledge_WhenWriteThrowsArgumentException_RejectsAndDropsItem()
|
|
{
|
|
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5, WriteException = new ArgumentException("bad") };
|
|
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
|
|
|
|
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "one", Alice());
|
|
|
|
Assert.Equal(AlarmAckOutcome.WriteRejected, result.Outcome);
|
|
Assert.Equal(unchecked((int)0x80070057), result.NativeStatus);
|
|
Assert.Contains("Write", result.Diagnostic, StringComparison.Ordinal);
|
|
Assert.Equal(new[] { 100 }, server.RemovedItemHandles);
|
|
Assert.DoesNotContain("Unregister", server.Operations);
|
|
}
|
|
|
|
[Fact]
|
|
public void Acknowledge_PastCacheCap_EvictsLeastRecentlyUsedItem()
|
|
{
|
|
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
|
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(1), "MxGateway", maxCachedItems: 2);
|
|
|
|
writer.Acknowledge("A.X.AckMsg", "1", Alice()); // handle 100
|
|
writer.Acknowledge("B.X.AckMsg", "2", Alice()); // handle 101
|
|
writer.Acknowledge("C.X.AckMsg", "3", Alice()); // handle 102, evicts A (100)
|
|
|
|
Assert.Equal(new[] { 100 }, server.RemovedItemHandles);
|
|
Assert.Equal(3, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal)));
|
|
}
|
|
|
|
[Fact]
|
|
public void Acknowledge_ReusedItem_IsMostRecentlyUsedForEviction()
|
|
{
|
|
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", maxCachedItems: 2);
|
|
|
|
writer.Acknowledge("A.X.AckMsg", "1", Alice()); // handle 100
|
|
writer.Acknowledge("B.X.AckMsg", "2", Alice()); // handle 101
|
|
writer.Acknowledge("A.X.AckMsg", "3", Alice()); // reuse 100, now most recent
|
|
writer.Acknowledge("C.X.AckMsg", "4", Alice()); // handle 102, evicts B (101)
|
|
|
|
Assert.Equal(new[] { 101 }, server.RemovedItemHandles);
|
|
Assert.Equal(3, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal)));
|
|
Assert.Contains("UnAdvise", server.Operations);
|
|
}
|
|
|
|
[Fact]
|
|
public void Acknowledge_AfterUnconfirmed_UsesFreshHandleSoStaleCompletionIsNotCredited()
|
|
{
|
|
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
|
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
|
|
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromMilliseconds(50), "MxGateway");
|
|
|
|
AlarmAckResult first = writer.Acknowledge("T.A.AckMsg", "one", Alice()); // handle 100, no completion
|
|
// The first write's late OnWriteComplete (handle 100) lands while the
|
|
// second write is in flight; the second write's own completion never comes.
|
|
server.OnWrite = (handle, item) => cache.Record(handle, 100, Rows(MxStatusCategory.Ok, 0));
|
|
AlarmAckResult second = writer.Acknowledge("T.A.AckMsg", "two", Alice());
|
|
|
|
Assert.Equal(AlarmAckOutcome.Unconfirmed, first.Outcome);
|
|
Assert.Equal(AlarmAckOutcome.Unconfirmed, second.Outcome);
|
|
Assert.Contains(100, server.RemovedItemHandles);
|
|
Assert.Equal(2, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal)));
|
|
Assert.Equal(new[] { 100, 101 }, server.WrittenItemHandles);
|
|
}
|
|
|
|
[Fact]
|
|
public void Acknowledge_AfterConfirmed_ReusesHandle()
|
|
{
|
|
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");
|
|
|
|
writer.Acknowledge("T.A.AckMsg", "one", Alice());
|
|
writer.Acknowledge("T.A.AckMsg", "two", Alice());
|
|
|
|
Assert.Equal(new[] { 100, 100 }, server.WrittenItemHandles);
|
|
Assert.Empty(server.RemovedItemHandles);
|
|
}
|
|
|
|
[Fact]
|
|
public void Acknowledge_AfterDirtyReAdd_ForgetsDroppedHandleCompletion()
|
|
{
|
|
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
|
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
|
|
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromMilliseconds(20), "MxGateway");
|
|
|
|
writer.Acknowledge("T.A.AckMsg", "one", Alice()); // handle 100, Unconfirmed
|
|
cache.Record(7, 100, Rows(MxStatusCategory.Ok, 0)); // its late completion
|
|
Assert.Equal(1, cache.Count);
|
|
server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.Ok, 0));
|
|
|
|
AlarmAckResult second = writer.Acknowledge("T.A.AckMsg", "two", Alice()); // drops 100, adds 101
|
|
|
|
Assert.Equal(AlarmAckOutcome.Applied, second.Outcome);
|
|
Assert.Equal(1, cache.Count);
|
|
Assert.Equal(0UL, cache.CurrentVersion(7, 100));
|
|
Assert.Equal(1UL, cache.CurrentVersion(7, 101));
|
|
}
|
|
|
|
[Fact]
|
|
public void LateCompletionForDroppedHandle_DoesNotRecreateCacheEntry()
|
|
{
|
|
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
|
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
|
|
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromMilliseconds(20), "MxGateway");
|
|
writer.Acknowledge("T.A.AckMsg", "one", Alice()); // handle 100, Unconfirmed
|
|
server.OnWrite = (handle, item) => writer.RecordWriteCompletion(handle, item, Rows(MxStatusCategory.Ok, 0));
|
|
writer.Acknowledge("T.A.AckMsg", "two", Alice()); // drops 100, adds 101, applied
|
|
|
|
writer.RecordWriteCompletion(7, 100, Rows(MxStatusCategory.Ok, 0)); // 100's late completion
|
|
writer.RecordWriteCompletion(8, 101, Rows(MxStatusCategory.Ok, 0)); // another registration
|
|
|
|
Assert.Equal(1, cache.Count);
|
|
Assert.Equal(0UL, cache.CurrentVersion(7, 100));
|
|
Assert.Equal(1UL, cache.CurrentVersion(7, 101));
|
|
}
|
|
|
|
[Fact]
|
|
public void Acknowledge_PastCacheCap_ForgetsEvictedHandleCompletion()
|
|
{
|
|
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", maxCachedItems: 2);
|
|
|
|
writer.Acknowledge("A.X.AckMsg", "1", Alice()); // handle 100
|
|
writer.Acknowledge("B.X.AckMsg", "2", Alice()); // handle 101
|
|
Assert.Equal(2, cache.Count);
|
|
writer.Acknowledge("C.X.AckMsg", "3", Alice()); // handle 102, evicts A (100)
|
|
|
|
Assert.Equal(2, cache.Count);
|
|
Assert.Equal(0UL, cache.CurrentVersion(7, 100));
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispose_ForgetsServerCompletions()
|
|
{
|
|
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
|
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
|
|
server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.Ok, 0));
|
|
AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway");
|
|
writer.Acknowledge("A.X.AckMsg", "1", Alice());
|
|
writer.Acknowledge("B.X.AckMsg", "2", Alice());
|
|
cache.Record(8, 100, Rows(MxStatusCategory.Ok, 0)); // another server's entry survives
|
|
|
|
writer.Dispose();
|
|
|
|
Assert.Equal(1, cache.Count);
|
|
Assert.Equal(1UL, cache.CurrentVersion(8, 100));
|
|
}
|
|
|
|
[Fact]
|
|
public void Acknowledge_WhenWriteDisconnected_ForgetsServerCompletions()
|
|
{
|
|
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");
|
|
writer.Acknowledge("A.X.AckMsg", "1", Alice()); // handle 100 recorded
|
|
server.WriteException = new COMException("x", unchecked((int)0x80010108));
|
|
|
|
writer.Acknowledge("A.X.AckMsg", "2", Alice());
|
|
|
|
Assert.Contains("Unregister", server.Operations);
|
|
Assert.Equal(0, cache.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void Acknowledge_WhenNonOkCompletionHasNoDetail_ReturnsMinusOne()
|
|
{
|
|
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
|
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
|
|
server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.SecurityError, 0));
|
|
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(-1, result.NativeStatus);
|
|
}
|
|
|
|
[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 Exception? AdviseException { get; set; }
|
|
|
|
public Exception? WriteException { get; set; }
|
|
|
|
public string? LastPassword { get; private set; }
|
|
|
|
public Action<int, int>? OnWrite { get; set; }
|
|
|
|
public List<int> RemovedItemHandles { get; } = new();
|
|
|
|
public List<int> WrittenItemHandles { get; } = new();
|
|
|
|
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");
|
|
RemovedItemHandles.Add(itemHandle);
|
|
}
|
|
|
|
public void Advise(int serverHandle, int itemHandle)
|
|
{
|
|
Operations.Add("Advise");
|
|
if (AdviseException is not null)
|
|
{
|
|
throw AdviseException;
|
|
}
|
|
}
|
|
|
|
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);
|
|
WrittenItemHandles.Add(itemHandle);
|
|
if (WriteException is not null)
|
|
{
|
|
throw WriteException;
|
|
}
|
|
|
|
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)
|
|
{
|
|
}
|
|
}
|