diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ClientAlarmMethodTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ClientAlarmMethodTests.cs
new file mode 100644
index 00000000..418a48fc
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ClientAlarmMethodTests.cs
@@ -0,0 +1,140 @@
+using Opc.Ua;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Client.Shared;
+using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
+
+///
+/// The shared client's alarm APIs (, the code behind Client.CLI's
+/// ack/confirm/shelve) against the real in-process server, over the wire, with the real
+/// session adapter. Commit 85f00327 moved the client onto the Part 9 method ids invoked on the condition
+/// node itself (it used to target nonexistent <id>.Condition / <id>.ShelvingState
+/// node ids); until this class it was only exercised against a fake session adapter.
+///
+///
+/// The condition id is passed exactly as a CLI user passes --node: the condition node's own id.
+/// Each call must return Good and move the condition — read back from the condition events the
+/// server reports, not from the call result alone. The client exposes no AddComment API, so it is not covered.
+///
+public sealed class ClientAlarmMethodTests
+{
+ private const NodePermissions All =
+ NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.AlarmRead
+ | NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm | NodePermissions.AlarmShelve
+ | NodePermissions.MethodCall;
+
+ [Fact]
+ public async Task The_client_acknowledges_confirms_and_shelves_a_scripted_condition()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ var rig = await StartAsync("ClientAlarmScripted", encrypted: false, ct);
+ try
+ {
+ rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
+ rig.MaterialiseScripted();
+ var current = ConditionMethodEventTests.PreState("Acknowledge", native: false) with { Message = "base" };
+ ConditionMethodEventTests.WireSimulatedOwners(rig, native: false, () => current, s => current = s);
+
+ using var observer = await rig.ConnectAsync(anonymous: false, ct);
+ var events = new RecordedEvents();
+ var subscription = await RecordedEvents.SubscribeAsync(observer, events, ct);
+ var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
+ await subscription.ApplyChangesAsync(ct);
+ rig.Project(native: false, current);
+ await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
+
+ using var client = await ConnectClientAsync(rig, ct);
+ var conditionId = ConditionNodeId(observer, native: false).ToString();
+
+ async Task Next(int expectedCount)
+ {
+ await rig.DrainProjectionsAsync();
+ await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count >= expectedCount);
+ return events.ConditionEvents(item, "base")[expectedCount - 1];
+ }
+
+ var n = 1;
+ StatusCode.IsGood(await client.AcknowledgeAlarmAsync(
+ conditionId, events.LatestEventId(item, "base"), "ack", ct)).ShouldBeTrue();
+ (await Next(++n))[RecordedEvents.AckedField].Value.ShouldBe(true);
+
+ StatusCode.IsGood(await client.ConfirmAlarmAsync(
+ conditionId, events.LatestEventId(item, "base"), "confirm", ct)).ShouldBeTrue();
+ (await Next(++n))[RecordedEvents.ConfirmedField].Value.ShouldBe(true);
+
+ StatusCode.IsGood(await client.ShelveAlarmAsync(conditionId, ShelveKind.OneShot, 0, ct)).ShouldBeTrue();
+ (await Next(++n))[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_OneShotShelved);
+
+ StatusCode.IsGood(await client.ShelveAlarmAsync(conditionId, ShelveKind.Unshelve, 0, ct)).ShouldBeTrue();
+ (await Next(++n))[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
+
+ StatusCode.IsGood(await client.ShelveAlarmAsync(conditionId, ShelveKind.Timed, 60, ct)).ShouldBeTrue();
+ (await Next(++n))[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_TimedShelved);
+
+ StatusCode.IsGood(await client.ShelveAlarmAsync(conditionId, ShelveKind.Unshelve, 0, ct)).ShouldBeTrue();
+ (await Next(++n))[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
+
+ await subscription.DeleteAsync(true, ct);
+ }
+ finally { await rig.DisposeAsync(); }
+ }
+
+ [Fact]
+ public async Task The_client_acknowledges_a_native_condition_and_is_refused_confirm_and_shelve()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ var rig = await StartAsync("ClientAlarmNative", encrypted: false, ct);
+ try
+ {
+ rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
+ rig.MaterialiseNative();
+ var current = ConditionMethodEventTests.PreState("Acknowledge", native: true) with { Message = "base" };
+ ConditionMethodEventTests.WireSimulatedOwners(rig, native: true, () => current, s => current = s);
+
+ using var observer = await rig.ConnectAsync(anonymous: false, ct);
+ var events = new RecordedEvents();
+ var subscription = await RecordedEvents.SubscribeAsync(observer, events, ct);
+ var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
+ await subscription.ApplyChangesAsync(ct);
+ rig.Project(native: true, current);
+ await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
+
+ using var client = await ConnectClientAsync(rig, ct);
+ var conditionId = ConditionNodeId(observer, native: true).ToString();
+ var eventId = events.LatestEventId(item, "base");
+
+ // Refusals first: they must not disturb the condition, so the ack below still lands as event #2.
+ (await client.ConfirmAlarmAsync(conditionId, eventId, "confirm", ct)).Code.ShouldBe(StatusCodes.BadNotSupported);
+ (await client.ShelveAlarmAsync(conditionId, ShelveKind.OneShot, 0, ct)).Code.ShouldBe(StatusCodes.BadNotSupported);
+ (await client.ShelveAlarmAsync(conditionId, ShelveKind.Timed, 60, ct)).Code.ShouldBe(StatusCodes.BadNotSupported);
+
+ StatusCode.IsGood(await client.AcknowledgeAlarmAsync(conditionId, eventId, "ack", ct)).ShouldBeTrue();
+ await rig.DrainProjectionsAsync();
+ await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count >= 2);
+ var ack = events.ConditionEvents(item, "base")[1];
+ ack[RecordedEvents.AckedField].Value.ShouldBe(true);
+ ack[RecordedEvents.ConfirmedField].Value.ShouldBe(true);
+ ack[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
+
+ await subscription.DeleteAsync(true, ct);
+ }
+ finally { await rig.DisposeAsync(); }
+ }
+
+ private static async Task ConnectClientAsync(ServerEventRig rig, CancellationToken ct)
+ {
+ var client = new OpcUaClientService();
+ await client.ConnectAsync(new ConnectionSettings
+ {
+ EndpointUrl = rig.EndpointUrl,
+ Username = "apikey",
+ Password = $"otua_{KeyId}_secret",
+ CertificateStorePath = Path.Combine(Path.GetTempPath(), $"otopcua-clientpki-{Guid.NewGuid():N}"),
+ }, ct);
+ return client;
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ConditionMethodEventTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ConditionMethodEventTests.cs
index be6eaf81..5b9c7daf 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ConditionMethodEventTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ConditionMethodEventTests.cs
@@ -394,7 +394,7 @@ public sealed class ConditionMethodEventTests
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: message);
/// The condition state a case starts from — one in which its method is accepted.
- private static AlarmConditionSnapshot PreState(string method, bool native)
+ internal static AlarmConditionSnapshot PreState(string method, bool native)
{
var s = new AlarmConditionSnapshot(
Active: true, Acknowledged: false, Confirmed: native, Enabled: true,
@@ -414,7 +414,7 @@ public sealed class ConditionMethodEventTests
/// Stands in for the scripted engine and the native driver: each routed command comes back as a
/// re-projection of the state it produces, from a pool thread.
///
- private static void WireSimulatedOwners(
+ internal static void WireSimulatedOwners(
ServerEventRig rig, bool native, Func current, Action set)
{
rig.Nm.NativeAlarmAckRouter = _ =>
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs
index 389f2b34..46a90c35 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs
@@ -41,6 +41,8 @@ internal sealed class ServerEventRig : IAsyncDisposable
private ServerEventRig(string pkiRoot) => _pkiRoot = pkiRoot;
+ public string EndpointUrl => $"opc.tcp://127.0.0.1:{_port}/OtOpcUa";
+
public OtOpcUaNodeManager Nm => _server.NodeManager!;
public SdkAddressSpaceSink Sink { get; private set; } = null!;
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj
index 1ea5dbac..544f524c 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj
@@ -29,6 +29,8 @@
over-the-wire denial is produced by the same component the hosted service arms rather than by
a direct call to the node manager's pass. -->
+
+