From d66269a99b4394d83238e4bd73386bec47af48ea Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 01:49:32 -0400 Subject: [PATCH] test(client): over-the-wire alarm method calls against the in-process server Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- .../ClientAlarmMethodTests.cs | 140 ++++++++++++++++++ .../ConditionMethodEventTests.cs | 4 +- .../ServerEventRig.cs | 2 + ...tOpcUa.OpcUaServer.IntegrationTests.csproj | 2 + 4 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ClientAlarmMethodTests.cs 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. --> + +