From 36eb14e88d9b69b61c7f92e219e34ce763e4f67f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 19 Jun 2026 02:14:58 -0400 Subject: [PATCH] feat(opcua): emit Bad blip + AuditWriteUpdateEvent + sync fail-fast on failed device write --- .../OtOpcUaNodeManager.cs | 9 +- .../NodeManagerWriteRevertTests.cs | 220 ++++++++++++++++++ 2 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteRevertTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 0acb5bea..cdd51cd9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -1064,6 +1064,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// /// Silent value-wise — this node manager carries no logger; the gateway logs the underlying write failure /// and the SDK trace captures any audit-report failure. + /// internal (not private) so the behavioural test drives the blip-then-settle continuation + /// against a booted node manager — see NodeManagerWriteRevertTests. /// /// The string id of the written variable node. /// The device-write outcome routed back by the gateway. @@ -1072,7 +1074,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The node's real status captured before the optimistic write. /// The writing principal's user-id string (the identity's DisplayName), threaded /// from to populate the audit event's ClientUserId; null when unknown. - private void RevertOptimisticWriteIfNeeded( + internal void RevertOptimisticWriteIfNeeded( string nodeId, NodeWriteOutcome outcome, object? optimisticValue, object? priorValue, StatusCode priorStatus, string? clientUserId) { @@ -1132,7 +1134,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The node's real pre-write value (the audit OldValue). /// The writing principal's user-id string; null when unknown. /// A populated, unreported . - private AuditWriteUpdateEventState BuildWriteFailureAuditEvent( + /// internal (not private) so NodeManagerWriteRevertTests can assert the populated + /// audit fields at the nearest deterministic seam (the end-to-end Server.ReportEvent dispatch would + /// need a subscribed event monitored-item to observe). + internal AuditWriteUpdateEventState BuildWriteFailureAuditEvent( BaseDataVariableState variable, NodeWriteOutcome outcome, object? optimisticValue, object? priorValue, string? clientUserId) { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteRevertTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteRevertTests.cs new file mode 100644 index 00000000..66057efa --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteRevertTests.cs @@ -0,0 +1,220 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// Write-outcome self-correction — the BEHAVIOURAL half of the failed-device-write surfacing +/// (the pure decision tables live in ). Boots a real +/// through (the same harness +/// uses) so the continuation runs against a live node +/// manager (real Lock + SystemContext + Server), and drives +/// + +/// directly: +/// +/// Item B (Bad-quality blip + settle): a FAILED outcome on a node still holding the +/// optimistic value settles the node back to its prior value/status; SUCCESS / a moved-on poll +/// leave the node untouched. +/// Item C (AuditWriteUpdateEvent): the built audit event-state carries SourceNode=node, +/// AttributeId=Value, OldValue=prior, NewValue=attempted, ClientUserId=writer, Status=false, and +/// the device Reason in its Message. (The end-to-end Server.ReportEvent dispatch needs a +/// subscribed event monitored-item to observe — the nearest deterministic seam is the builder.) +/// +/// +public sealed class NodeManagerWriteRevertTests : IDisposable +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private readonly string _pkiRoot = Path.Combine( + Path.GetTempPath(), + $"otopcua-write-revert-{Guid.NewGuid():N}"); + + // ───────────────────────────── Item B — Bad-quality blip + settle ───────────────────────────── + + /// (B1) A FAILED device-write outcome on a node that still holds the optimistic value SETTLES the + /// node back to its captured prior value + prior status (the transient Bad blip coalesces within one + /// publishing interval — see the method remarks — so we assert the observable settled state). + [Fact] + public async Task Failed_outcome_settles_node_back_to_prior_value_and_status() + { + var (host, server) = await BootAsync(); + var nm = server.NodeManager!; + + nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true); + // Prior state = Good 7; the SDK then optimistically applied 42 (the write the device will reject). + nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow); + var node = nm.TryGetVariable("eq-1/sp")!; + node.Value = 42; // simulate the SDK's optimistic apply of the rejected write + + nm.RevertOptimisticWriteIfNeeded( + "eq-1/sp", + outcome: new NodeWriteOutcome(false, "device rejected register 42"), + optimisticValue: 42, + priorValue: 7, + priorStatus: StatusCodes.Good, + clientUserId: "op"); + + node.Value.ShouldBe(7); // settled back to prior value + node.StatusCode.ShouldBe((StatusCode)StatusCodes.Good); // settled back to prior status (NOT stuck Bad) + + await host.DisposeAsync(); + } + + /// (B2) A SUCCESS outcome NEVER reverts — the optimistic value stands (the next poll re-confirms + /// it via the normal WriteValue path). + [Fact] + public async Task Successful_outcome_leaves_optimistic_value() + { + var (host, server) = await BootAsync(); + var nm = server.NodeManager!; + + nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true); + nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow); + var node = nm.TryGetVariable("eq-1/sp")!; + node.Value = 42; + + nm.RevertOptimisticWriteIfNeeded( + "eq-1/sp", + outcome: new NodeWriteOutcome(true, null), + optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op"); + + node.Value.ShouldBe(42); // success ⇒ optimistic value stands + + await host.DisposeAsync(); + } + + /// (B3) A failed outcome where a fresh driver poll already moved the node OFF the optimistic value + /// does NOT clobber the poll — the node keeps the fresh polled value (the still-holds-optimistic guard). + [Fact] + public async Task Failed_outcome_does_not_clobber_a_fresh_poll() + { + var (host, server) = await BootAsync(); + var nm = server.NodeManager!; + + nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true); + nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow); + var node = nm.TryGetVariable("eq-1/sp")!; + node.Value = 99; // a fresh poll already republished the confirmed register value (NOT the optimistic 42) + + nm.RevertOptimisticWriteIfNeeded( + "eq-1/sp", + outcome: new NodeWriteOutcome(false, "device rejected"), + optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op"); + + node.Value.ShouldBe(99); // poll value preserved — not reverted to 7 + + await host.DisposeAsync(); + } + + /// (B4) An unknown node id (rebuilt/removed in the interim) is a no-op — the continuation must not + /// throw out of its fire-and-forget body. + [Fact] + public async Task Failed_outcome_on_missing_node_is_a_noop() + { + var (host, server) = await BootAsync(); + var nm = server.NodeManager!; + + Should.NotThrow(() => nm.RevertOptimisticWriteIfNeeded( + "eq-1/gone", + outcome: new NodeWriteOutcome(false, "device rejected"), + optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op")); + + await host.DisposeAsync(); + } + + // ───────────────────────────── Item C — AuditWriteUpdateEvent builder ───────────────────────────── + + /// (C1) The built audit event reflects the rejected write: SourceNode = the node, AttributeId = + /// Value, OldValue = prior, NewValue = attempted, ClientUserId = the writer, the AuditEvent Status boolean + /// = false (failed), and the device Reason is carried in the Message. + [Fact] + public async Task Built_audit_event_reflects_the_failed_write() + { + var (host, server) = await BootAsync(); + var nm = server.NodeManager!; + + nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true); + nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow); + var node = nm.TryGetVariable("eq-1/sp")!; + + var audit = nm.BuildWriteFailureAuditEvent( + node, + outcome: new NodeWriteOutcome(false, "FC06 rejected"), + optimisticValue: 42, + priorValue: 7, + clientUserId: "op"); + + audit.ShouldNotBeNull(); + audit.SourceNode.Value.ShouldBe(node.NodeId); + audit.AttributeId.Value.ShouldBe((uint)Attributes.Value); + audit.OldValue.Value.ShouldBe(7); + audit.NewValue.Value.ShouldBe(42); + audit.ClientUserId.Value.ShouldBe("op"); + audit.Status.Value.ShouldBeFalse(); // AuditEvent Status = false ⇒ the write failed + audit.Message.Value.Text.ShouldContain("FC06 rejected"); // device Reason carried in the Message + + await host.DisposeAsync(); + } + + /// (C2) When the device gives no Reason, the audit Message still carries a sensible default + /// ("device write rejected") rather than an empty/null message. + [Fact] + public async Task Built_audit_event_uses_default_reason_when_none_supplied() + { + var (host, server) = await BootAsync(); + var nm = server.NodeManager!; + + nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true); + nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow); + var node = nm.TryGetVariable("eq-1/sp")!; + + var audit = nm.BuildWriteFailureAuditEvent( + node, + outcome: new NodeWriteOutcome(false, null), + optimisticValue: 42, priorValue: 7, clientUserId: "op"); + + audit.Message.Value.Text.ShouldContain("device write rejected"); + + await host.DisposeAsync(); + } + + private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() + { + var host = new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.WriteRevertTest", + ApplicationUri = $"urn:OtOpcUa.WriteRevertTest:{Guid.NewGuid():N}", + OpcUaPort = AllocateFreePort(), + PublicHostname = "localhost", + PkiStoreRoot = _pkiRoot, + }, + NullLogger.Instance); + + var server = new OtOpcUaSdkServer(); + await host.StartAsync(server, Ct); + return (host, server); + } + + 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; + } + + /// Cleans up the PKI root directory. + public void Dispose() + { + if (Directory.Exists(_pkiRoot)) + { + try { Directory.Delete(_pkiRoot, recursive: true); } + catch { /* best-effort cleanup */ } + } + } +}