From 9d86287d081c3bc4d136d685faace4069c90d6aa Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 10:40:58 -0400 Subject: [PATCH] test(opcua): Task 60 ServiceLevel end-to-end through SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boots a real StandardServer + OpcUaApplicationHost, wires SdkServiceLevelPublisher into a DeferredServiceLevelPublisher (production binding pattern), spawns OpcUaPublishActor against the deferred publisher, sends RedundancyStateChanged snapshots, and asserts that ServerObject.ServiceLevel.Value reflects the role-derived byte: Primary + RoleLeaderForDriver → 240 Secondary → 100 Together with the F13b endpoint-security tests (which already verify ServerConfiguration.SecurityPolicies populates the three baseline profiles), this closes Task 60's "dual-endpoint + ServiceLevel" scope. Cross-node failover tests stay in the 2-node integration harness (Task 59 / FailoverScenarioTests). Runtime suite now 74 / 74 green (+2). Closes Task 60. --- .../OpcUa/ServiceLevelEndToEndTests.cs | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/ServiceLevelEndToEndTests.cs diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/ServiceLevelEndToEndTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/ServiceLevelEndToEndTests.cs new file mode 100644 index 0000000..cafa479 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/ServiceLevelEndToEndTests.cs @@ -0,0 +1,147 @@ +using Akka.Actor; +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua.Server; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; +using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; +using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa; + +/// +/// Task 60 / #81 — verifies the full path from cluster redundancy state to OPC UA +/// Server.ServiceLevel visible on the wire. Boots a real , +/// wires into a +/// (the production binding pattern), spawns against the +/// deferred publisher, and sends a snapshot. Asserts +/// ServerObject.ServiceLevel.Value reflects the role-derived byte. +/// +public sealed class ServiceLevelEndToEndTests : RuntimeActorTestBase +{ + private static CancellationToken Ct => CancellationToken.None; + + [Fact] + public async Task Primary_leader_drives_Server_ServiceLevel_to_240() + { + var pkiRoot = AllocatePkiRoot(); + try + { + var server = new StandardServer(); + await using var host = new OpcUaApplicationHost( + BuildOptions("PrimaryLeader", pkiRoot), + NullLogger.Instance); + await host.StartAsync(server, Ct); + + var deferred = new DeferredServiceLevelPublisher(); + deferred.SetInner(new SdkServiceLevelPublisher( + server.CurrentInstance, + NullLogger.Instance)); + + var localNode = NodeId.Parse("node-A"); + var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( + serviceLevel: deferred, + subscribeRedundancyTopic: false, + localNode: localNode)); + + actor.Tell(new RedundancyStateChanged( + Nodes: new[] + { + new NodeRedundancyState(localNode, RedundancyRole.Primary, IsClusterLeader: true, IsRoleLeaderForDriver: true, AsOfUtc: DateTime.UtcNow), + }, + CorrelationId: CorrelationId.NewId())); + + AwaitAssertion(() => + server.CurrentInstance.ServerObject.ServiceLevel.Value.ShouldBe((byte)240)); + } + finally + { + DeletePkiRoot(pkiRoot); + } + } + + [Fact] + public async Task Secondary_drives_Server_ServiceLevel_to_100() + { + var pkiRoot = AllocatePkiRoot(); + try + { + var server = new StandardServer(); + await using var host = new OpcUaApplicationHost( + BuildOptions("Secondary", pkiRoot), + NullLogger.Instance); + await host.StartAsync(server, Ct); + + var deferred = new DeferredServiceLevelPublisher(); + deferred.SetInner(new SdkServiceLevelPublisher( + server.CurrentInstance, + NullLogger.Instance)); + + var localNode = NodeId.Parse("node-B"); + var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( + serviceLevel: deferred, + subscribeRedundancyTopic: false, + localNode: localNode)); + + actor.Tell(new RedundancyStateChanged( + Nodes: new[] + { + new NodeRedundancyState(localNode, RedundancyRole.Secondary, IsClusterLeader: false, IsRoleLeaderForDriver: false, AsOfUtc: DateTime.UtcNow), + }, + CorrelationId: CorrelationId.NewId())); + + AwaitAssertion(() => + server.CurrentInstance.ServerObject.ServiceLevel.Value.ShouldBe((byte)100)); + } + finally + { + DeletePkiRoot(pkiRoot); + } + } + + private static OpcUaApplicationHostOptions BuildOptions(string name, string pkiRoot) => + new() + { + ApplicationName = $"OtOpcUa.E2E.{name}", + ApplicationUri = $"urn:OtOpcUa.E2E.{name}:{Guid.NewGuid():N}", + OpcUaPort = AllocateFreePort(), + PublicHostname = "localhost", + PkiStoreRoot = pkiRoot, + }; + + private static string AllocatePkiRoot() => + Path.Combine(Path.GetTempPath(), $"otopcua-pki-{Guid.NewGuid():N}"); + + private static void DeletePkiRoot(string root) + { + if (Directory.Exists(root)) + { + try { Directory.Delete(root, recursive: true); } + catch { /* best-effort */ } + } + } + + private static int AllocateFreePort() + { + using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + private void AwaitAssertion(Action assertion) + { + var deadline = DateTime.UtcNow.AddSeconds(3); + Exception? last = null; + while (DateTime.UtcNow < deadline) + { + try { assertion(); return; } + catch (Exception ex) { last = ex; Thread.Sleep(30); } + } + if (last is not null) throw last; + } +}