From 9cddc9306bd2bc2df698ac4e51e7572820978014 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 10:37:52 -0500 Subject: [PATCH] test: preserve server SDK 2.0 value security and history contracts --- .../AlarmCommandRouterTests.cs | 20 +- .../DataPlaneAuthorizerTests.cs | 2 +- .../EquipmentWriteGateTests.cs | 18 +- .../History/HistoryUpdateTests.cs | 149 ++++++----- .../HistoryPagingTests.cs | 4 +- ...oryServerCapabilitiesAdvertisementTests.cs | 6 +- .../NodeManagerAclEnforcementTests.cs | 36 +-- .../NodeManagerAlarmSourceFieldsTests.cs | 30 +-- .../NodeManagerArrayTests.cs | 8 +- .../NodeManagerBatchWriteTests.cs | 2 +- .../NodeManagerHistoryReadBoundsTests.cs | 56 ++-- .../NodeManagerHistoryReadConcurrencyTests.cs | 20 +- ...odeManagerHistoryReadEventsHorizonTests.cs | 28 +- ...NodeManagerHistoryReadEventsPagingTests.cs | 60 ++--- ...ManagerHistoryReadEventsProjectionTests.cs | 46 ++-- .../NodeManagerHistoryReadEventsTests.cs | 56 ++-- .../NodeManagerHistoryReadModifiedTests.cs | 68 ++--- .../NodeManagerHistoryReadPageFillTests.cs | 30 +-- .../NodeManagerHistoryReadPagingTests.cs | 133 ++++++---- .../NodeManagerHistoryReadRawCapFuzzTests.cs | 32 +-- .../NodeManagerHistoryReadTests.cs | 189 ++++++++------ .../NodeManagerKeyUsabilityGateTests.cs | 22 +- .../NodeManagerLockTimeBenchmarkTests.cs | 4 +- ...NodeManagerMaxReturnDataValuesSyncTests.cs | 6 +- .../NodeManagerModelChangeFilterTests.cs | 14 +- .../NodeManagerModelChangeOnAddTests.cs | 8 +- ...ManagerNativeAlarmRebuildCarryOverTests.cs | 64 ++--- .../NodeManagerStage6EnforcementTests.cs | 54 ++-- .../NodeManagerStaleMonitoredNodeTests.cs | 12 +- .../NodeManagerSurgicalRemoveTests.cs | 4 +- .../NodeManagerSurgicalShapeUpdateTests.cs | 18 +- .../NodeManagerWriteRevertTests.cs | 16 +- .../OpcUaApplicationHostImpersonationTests.cs | 22 +- .../OpcUaApplicationHostSecurityTests.cs | 16 +- .../OpcUaApplicationHostServerArrayTests.cs | 4 +- .../OpcUaApplicationHostTests.cs | 7 +- .../ReverseWalkTests.cs | 4 +- .../RoleCarryingUserIdentityTests.cs | 38 +++ .../Sdk2ActivationPeerTests.cs | 45 ++++ .../Sdk2CertificateLifecycleTests.cs | 64 +++++ .../Sdk2EquipmentVariableTests.cs | 243 ++++++++++++++++++ .../SdkAddressSpaceSinkTests.cs | 81 +++--- .../SdkServiceLevelPublisherTests.cs | 11 +- .../SdkTestValues.cs | 8 + .../SessionHistoryContinuationStoreTests.cs | 98 +++++++ 45 files changed, 1215 insertions(+), 641 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/RoleCarryingUserIdentityTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Sdk2ActivationPeerTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Sdk2CertificateLifecycleTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Sdk2EquipmentVariableTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkTestValues.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SessionHistoryContinuationStoreTests.cs diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs index 74f5d45c..32a48a0b 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs @@ -74,7 +74,7 @@ public sealed class AlarmCommandRouterTests : IDisposable var ctx = SessionContext(server, "bob", "ReadOnly"); // no AlarmAck var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("nope")); - result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); captured.ShouldBeEmpty(); await host.DisposeAsync(); @@ -100,7 +100,7 @@ public sealed class AlarmCommandRouterTests : IDisposable var ctx = new ServerSystemContext(server.CurrentInstance); var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("x")); - result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); captured.ShouldBeEmpty(); await host.DisposeAsync(); @@ -122,7 +122,7 @@ public sealed class AlarmCommandRouterTests : IDisposable condition!.OnConfirm.ShouldNotBeNull(); var ctx = SessionContext(server, "carol", OpcUaDataPlaneRoles.AlarmAck); - var result = condition.OnConfirm!(ctx, condition, EventIdBytes(), comment: null); + var result = condition.OnConfirm!(ctx, condition, EventIdBytes(), comment: default); result.ShouldBe(ServiceResult.Good); captured.Count.ShouldBe(1); @@ -262,7 +262,7 @@ public sealed class AlarmCommandRouterTests : IDisposable var ctx = SessionContext(server, "heidi", "ReadOnly"); // no AlarmAck var result = condition!.OnShelve!(ctx, condition, shelving: true, oneShot: true, shelvingTime: 0.0); - result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); captured.ShouldBeEmpty(); await host.DisposeAsync(); @@ -383,7 +383,7 @@ public sealed class AlarmCommandRouterTests : IDisposable var ctx = new ServerSystemContext(server.CurrentInstance); var result = condition!.OnEnableDisable!(ctx, condition, enabling: false); - result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); captured.ShouldBeEmpty(); await host.DisposeAsync(); @@ -409,7 +409,7 @@ public sealed class AlarmCommandRouterTests : IDisposable var ctx = SessionContext(server, "kara", OpcUaDataPlaneRoles.AlarmAck); var result = condition.OnEnableDisable!(ctx, condition, enabling: false); - result.StatusCode.Code.ShouldBe(StatusCodes.BadNotSupported); + result.StatusCode.Code.ShouldBe(StatusCodes.BadNotSupported.Code); captured.ShouldBeEmpty(); await host.DisposeAsync(); @@ -478,7 +478,7 @@ public sealed class AlarmCommandRouterTests : IDisposable var ctx = SessionContext(server, "quinn", OpcUaDataPlaneRoles.AlarmAck); var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("ack")); - result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); result.LocalizedText.Text.ShouldBe("no Galaxy ack account for an anonymous session"); native.ShouldBeEmpty(); checkedAcks.ShouldHaveSingleItem().ConditionNodeId.ShouldBe("alm-nak5"); @@ -547,7 +547,7 @@ public sealed class AlarmCommandRouterTests : IDisposable var ctx = SessionContext(server, "pete", "ReadOnly"); // non-null identity, but no AlarmAck var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("nope")); - result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); native.ShouldBeEmpty(); scripted.ShouldBeEmpty(); @@ -576,7 +576,7 @@ public sealed class AlarmCommandRouterTests : IDisposable var ctx = new ServerSystemContext(server.CurrentInstance); var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("x")); - result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); native.ShouldBeEmpty(); scripted.ShouldBeEmpty(); @@ -727,7 +727,7 @@ public sealed class AlarmCommandRouterTests : IDisposable return new ServerSystemContext(server.CurrentInstance) { UserIdentity = identity }; } - private static byte[] EventIdBytes() => Guid.NewGuid().ToByteArray(); + private static ByteString EventIdBytes() => ByteString.From(Guid.NewGuid().ToByteArray()); private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DataPlaneAuthorizerTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DataPlaneAuthorizerTests.cs index 4c288257..ed49827f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DataPlaneAuthorizerTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DataPlaneAuthorizerTests.cs @@ -544,7 +544,7 @@ public sealed class DataPlaneAuthorizerTests private static void Veto(ServiceResult? result) { result.ShouldNotBeNull(); - result!.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + result!.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); } private static DataPlaneAuthorizer Gate( diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/EquipmentWriteGateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/EquipmentWriteGateTests.cs index b201055b..c74077fc 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/EquipmentWriteGateTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/EquipmentWriteGateTests.cs @@ -32,7 +32,7 @@ public sealed class EquipmentWriteGateTests gatewayWired: true); result.ShouldNotBeNull(); - result!.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + result!.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); } /// (b) An identity WITHOUT the WriteOperate role is denied with @@ -46,7 +46,7 @@ public sealed class EquipmentWriteGateTests gatewayWired: true); result.ShouldNotBeNull(); - result!.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + result!.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); } /// (c) An identity WITH the WriteOperate role and a wired gateway passes the gate @@ -74,8 +74,8 @@ public sealed class EquipmentWriteGateTests gatewayWired: false); result.ShouldNotBeNull(); - result!.StatusCode.Code.ShouldBe(StatusCodes.BadNotWritable); - result.LocalizedText.Text.ShouldContain("writes unavailable"); + result!.StatusCode.Code.ShouldBe(StatusCodes.BadNotWritable.Code); + result.LocalizedText.Text!.ShouldContain("writes unavailable"); } /// @@ -118,7 +118,7 @@ public sealed class EquipmentWriteGateTests var result = OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: null, node); result.ShouldNotBeNull(); - result!.StatusCode.Code.ShouldBe(StatusCodes.BadTypeMismatch); + result!.StatusCode.Code.ShouldBe(StatusCodes.BadTypeMismatch.Code); } /// (A2) A type-matching value (Int32 payload into an Int32 node) proceeds (returns null). @@ -149,7 +149,7 @@ public sealed class EquipmentWriteGateTests var result = OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: "not-a-bool", node); result.ShouldNotBeNull(); - result!.StatusCode.Code.ShouldBe(StatusCodes.BadTypeMismatch); + result!.StatusCode.Code.ShouldBe(StatusCodes.BadTypeMismatch.Code); } /// (A5) Confidence-gated defer: a node whose DataType is the abstract BaseDataType @@ -341,7 +341,7 @@ public sealed class EquipmentWriteGateTests [Fact] public void Range_only_whole_value_writes_proceed() { - OtOpcUaNodeManager.EvaluateEquipmentWriteRange(NumericRange.Empty).ShouldBeNull(); + OtOpcUaNodeManager.EvaluateEquipmentWriteRange(default(NumericRange)).ShouldBeNull(); OtOpcUaNodeManager.EvaluateEquipmentWriteRange(NumericRange.Parse("1"))!.StatusCode .ShouldBe((StatusCode)StatusCodes.BadWriteNotSupported); } @@ -350,7 +350,7 @@ public sealed class EquipmentWriteGateTests { var node = ValueNode(dataType); node.ValueRank = ValueRanks.OneDimension; - node.ArrayDimensions = new ReadOnlyList(new UInt32Collection(new[] { length })); + node.ArrayDimensions = (ArrayOf)[length]; return node; } @@ -358,7 +358,7 @@ public sealed class EquipmentWriteGateTests { NodeId = new NodeId("eq/x", 2), BrowseName = new QualifiedName("x", 2), - DisplayName = "x", + DisplayName = new LocalizedText("x"), DataType = dataType, ValueRank = ValueRanks.Scalar, }; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs index 1ddf9a28..d7952eec 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs @@ -57,16 +57,16 @@ public sealed class HistoryUpdateTests : IDisposable var (results, errors) = Insert(nm, KeyIdentity(), nodeId, Value(T1, 42.5)); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); - results[0].StatusCode.Code.ShouldBe(StatusCodes.Good); - results[0].OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); + results[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); + results[0].OperationResults.ToArray()!.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted.Code]); var call = updater.UpdateCalls.ShouldHaveSingleItem(); call.Tag.ShouldBe(Tagname); call.Mode.ShouldBe(HistoryUpdateMode.Insert); var sent = call.Values.ShouldHaveSingleItem(); sent.TimestampUtc.ShouldBe(T1); sent.Value.ShouldBe(HistorianValue.FromDouble(42.5)); - sent.StatusCode.ShouldBe(StatusCodes.Good); + sent.StatusCode.ShouldBe(StatusCodes.Good.Code); } finally { await host.DisposeAsync(); } } @@ -86,8 +86,8 @@ public sealed class HistoryUpdateTests : IDisposable var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d)); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); - results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); + results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); updater.UpdateCalls.ShouldBeEmpty(); } finally { await host.DisposeAsync(); } @@ -108,8 +108,8 @@ public sealed class HistoryUpdateTests : IDisposable var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d)); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); - results[0].OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); + results[0].OperationResults.ToArray()!.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted.Code]); updater.UpdateCalls.Count.ShouldBe(1); } finally { await host.DisposeAsync(); } @@ -130,8 +130,8 @@ public sealed class HistoryUpdateTests : IDisposable var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.WriteOperate), nodeId, Value(T1, 1d)); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); - results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); + results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); updater.UpdateCalls.ShouldBeEmpty(); } finally { await host.DisposeAsync(); } @@ -155,12 +155,12 @@ public sealed class HistoryUpdateTests : IDisposable var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath); var identity = KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate); - Insert(nm, identity, nodeId, Value(T1, 1d)).Errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + Insert(nm, identity, nodeId, Value(T1, 1d)).Errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); clock.Advance(TimeSpan.FromMinutes(20)); Insert(nm, identity, nodeId, Value(T2, 2d)).Errors[0].StatusCode.Code - .ShouldBe(StatusCodes.BadUserAccessDenied); + .ShouldBe(StatusCodes.BadUserAccessDenied.Code); updater.UpdateCalls.Count.ShouldBe(1); } finally { await host.DisposeAsync(); } @@ -184,12 +184,12 @@ public sealed class HistoryUpdateTests : IDisposable var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath); var denied = Insert(nm, AnonymousIdentity(OpcUaDataPlaneRoles.WriteOperate), nodeId, Value(T1, 1d)); - denied.Errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); - denied.Results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + denied.Errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); + denied.Results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); updater.UpdateCalls.ShouldBeEmpty(); var allowed = Insert(nm, AnonymousIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T2, 2d)); - allowed.Errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + allowed.Errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); updater.UpdateCalls.Count.ShouldBe(1); } finally { await host.DisposeAsync(); } @@ -217,7 +217,7 @@ public sealed class HistoryUpdateTests : IDisposable (variable.AccessLevel & AccessLevels.HistoryWrite).ShouldBe((byte)0); var (_, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d)); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); } finally { await host.DisposeAsync(); } } @@ -248,7 +248,7 @@ public sealed class HistoryUpdateTests : IDisposable .ShouldBe((byte)0, "clearing the updater retracts the bit"); var (_, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d)); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); updater.UpdateCalls.ShouldBeEmpty(); } finally { await host.DisposeAsync(); } @@ -279,8 +279,8 @@ public sealed class HistoryUpdateTests : IDisposable var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d)); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); - results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported, "set by the override, not left for the master node manager"); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); + results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code, "set by the override, not left for the master node manager"); updater.UpdateCalls.ShouldBeEmpty(); } finally { await host.DisposeAsync(); } @@ -351,8 +351,8 @@ public sealed class HistoryUpdateTests : IDisposable var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d)); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadInternalError); - results[0].StatusCode.Code.ShouldBe(StatusCodes.BadInternalError); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadInternalError.Code); + results[0].StatusCode.Code.ShouldBe(StatusCodes.BadInternalError.Code); } finally { await host.DisposeAsync(); } } @@ -378,7 +378,7 @@ public sealed class HistoryUpdateTests : IDisposable { var (results, _) = Call(nm, identity, typeof(DeleteRawModifiedDetails), new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = start, EndTime = end, IsDeleteModified = false }); - results[0].StatusCode.Code.ShouldBe(StatusCodes.BadInvalidArgument); + results[0].StatusCode.Code.ShouldBe(StatusCodes.BadInvalidArgument.Code); } updater.DeleteRawCalls.ShouldBeEmpty(); @@ -402,30 +402,30 @@ public sealed class HistoryUpdateTests : IDisposable var nm = server.NodeManager!; var updater = new FakeUpdater { - UpdateCodes = [StatusCodes.GoodEntryInserted, StatusCodes.BadEntryExists], + UpdateCodes = [StatusCodes.GoodEntryInserted.Code, StatusCodes.BadEntryExists.Code], }; nm.HistorianDataUpdater = updater; WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate); var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath); - var bad = new DataValue(new Variant(new[] { 1d, 2d })) { SourceTimestamp = T2 }; + var bad = new DataValue(new Variant(new[] { 1d, 2d }), StatusCodes.Good, T2); var noTime = new DataValue(new Variant(7d)); // SourceTimestamp defaults to DateTime.MinValue - var uncertain = new DataValue(new Variant(3)) { SourceTimestamp = T2, StatusCode = StatusCodes.UncertainLastUsableValue }; + var uncertain = new DataValue(new Variant(3), StatusCodes.UncertainLastUsableValue, T2); var (results, errors) = Update(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, PerformUpdateType.Insert, Value(T1, 1d), noTime, bad, uncertain); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); - results[0].StatusCode.Code.ShouldBe(StatusCodes.Good); - results[0].OperationResults.Select(c => c.Code).ShouldBe( + errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); + results[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); + results[0].OperationResults.ToArray()!.Select(c => c.Code).ShouldBe( [ - StatusCodes.GoodEntryInserted, StatusCodes.BadInvalidTimestamp, StatusCodes.BadTypeMismatch, - StatusCodes.BadEntryExists, + StatusCodes.GoodEntryInserted.Code, StatusCodes.BadInvalidTimestamp.Code, StatusCodes.BadTypeMismatch.Code, + StatusCodes.BadEntryExists.Code, ]); var call = updater.UpdateCalls.ShouldHaveSingleItem(); call.Values.Count.ShouldBe(2); call.Values[1].Value.ShouldBe(HistorianValue.FromInt64(3)); - call.Values[1].StatusCode.ShouldBe(StatusCodes.UncertainLastUsableValue); + call.Values[1].StatusCode.ShouldBe(StatusCodes.UncertainLastUsableValue.Code); } finally { await host.DisposeAsync(); } } @@ -449,7 +449,7 @@ public sealed class HistoryUpdateTests : IDisposable var (_, removeErrors) = Update(nm, identity, nodeId, PerformUpdateType.Remove, Value(T1, 1d)); updater.UpdateCalls.Select(c => c.Mode).ShouldBe([HistoryUpdateMode.Replace, HistoryUpdateMode.Update]); - removeErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + removeErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); } finally { await host.DisposeAsync(); } } @@ -470,7 +470,7 @@ public sealed class HistoryUpdateTests : IDisposable var (_, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), unsNodeId, Value(T1, 5d)); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); updater.UpdateCalls.ShouldHaveSingleItem().Tag.ShouldBe(Tagname); } finally { await host.DisposeAsync(); } @@ -484,7 +484,7 @@ public sealed class HistoryUpdateTests : IDisposable try { var nm = server.NodeManager!; - var updater = new FakeUpdater { DeleteRawCode = StatusCodes.BadNoData }; + var updater = new FakeUpdater { DeleteRawCode = StatusCodes.BadNoData.Code }; nm.HistorianDataUpdater = updater; WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate); var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath); @@ -495,10 +495,10 @@ public sealed class HistoryUpdateTests : IDisposable var (_, modifiedErrors) = Call(nm, identity, typeof(DeleteRawModifiedDetails), new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = T1, EndTime = T2, IsDeleteModified = true }); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); - results[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoData); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); + results[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoData.Code); updater.DeleteRawCalls.ShouldHaveSingleItem().ShouldBe((Tagname, T1, T2)); - modifiedErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + modifiedErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); } finally { await host.DisposeAsync(); } } @@ -524,7 +524,7 @@ public sealed class HistoryUpdateTests : IDisposable Call(nm, identity, typeof(DeleteRawModifiedDetails), new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = T1, EndTime = T2, IsDeleteModified = false }); Call(nm, identity, typeof(DeleteAtTimeDetails), - new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = new DateTimeCollection { T1 } }); + new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = new List { T1 } }); updater.Callers.Count.ShouldBe(3); updater.Callers.ShouldAllBe(c => c == new HistoryUpdateContext(Principal, KeyId)); @@ -547,7 +547,7 @@ public sealed class HistoryUpdateTests : IDisposable var (_, errors) = Insert(nm, AnonymousIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d)); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); updater.Callers.ShouldHaveSingleItem().ShouldBe(new HistoryUpdateContext(OpcUaPrincipals.Anonymous, null)); } finally { await host.DisposeAsync(); } @@ -561,16 +561,16 @@ public sealed class HistoryUpdateTests : IDisposable try { var nm = server.NodeManager!; - var updater = new FakeUpdater { DeleteAtTimeCodes = [StatusCodes.Good, StatusCodes.BadNoEntryExists] }; + var updater = new FakeUpdater { DeleteAtTimeCodes = [StatusCodes.Good.Code, StatusCodes.BadNoEntryExists.Code] }; nm.HistorianDataUpdater = updater; WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate); var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath); var (results, errors) = Call(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), typeof(DeleteAtTimeDetails), - new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = new DateTimeCollection { T1, T2 } }); + new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = new List { T1, T2 } }); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); - results[0].OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.Good, StatusCodes.BadNoEntryExists]); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); + results[0].OperationResults.ToArray()!.Select(c => c.Code).ShouldBe([StatusCodes.Good.Code, StatusCodes.BadNoEntryExists.Code]); updater.DeleteAtTimeCalls.ShouldHaveSingleItem().ShouldBe([T1, T2]); } finally { await host.DisposeAsync(); } @@ -592,7 +592,7 @@ public sealed class HistoryUpdateTests : IDisposable var (_, errors) = Call(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), typeof(UpdateEventDetails), new UpdateEventDetails { NodeId = nodeId, PerformInsertReplace = PerformUpdateType.Insert }); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); updater.UpdateCalls.ShouldBeEmpty(); } finally { await host.DisposeAsync(); } @@ -624,11 +624,11 @@ public sealed class HistoryUpdateTests : IDisposable PerformUpdateType.Update, Value(T1, 1d), Value(atBound.AddTicks(1), 2d), Value(atBound, 3d), Value(new DateTime(2062, 1, 1, 0, 0, 0, DateTimeKind.Utc), 4d)); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); - results[0].OperationResults.Select(c => c.Code).ShouldBe( + errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); + results[0].OperationResults.ToArray()!.Select(c => c.Code).ShouldBe( [ - StatusCodes.GoodEntryInserted, StatusCodes.BadOutOfRange, StatusCodes.GoodEntryInserted, - StatusCodes.BadOutOfRange, + StatusCodes.GoodEntryInserted.Code, StatusCodes.BadOutOfRange.Code, StatusCodes.GoodEntryInserted.Code, + StatusCodes.BadOutOfRange.Code, ]); updater.UpdateCalls.ShouldHaveSingleItem().Values.Select(v => v.TimestampUtc).ShouldBe([T1, atBound]); } @@ -656,7 +656,7 @@ public sealed class HistoryUpdateTests : IDisposable var (results, _) = Update(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, type, Value(Start.AddDays(1).AddSeconds(1), 1d)); - results[0].OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.BadOutOfRange]); + results[0].OperationResults.ToArray()!.Select(c => c.Code).ShouldBe([StatusCodes.BadOutOfRange.Code]); updater.UpdateCalls.ShouldBeEmpty(); } finally { await host.DisposeAsync(); } @@ -680,7 +680,7 @@ public sealed class HistoryUpdateTests : IDisposable var (results, _) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(farFuture, 1d)); - results[0].OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]); + results[0].OperationResults.ToArray()!.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted.Code]); updater.UpdateCalls.ShouldHaveSingleItem().Values.ShouldHaveSingleItem().TimestampUtc.ShouldBe(farFuture); } finally { await host.DisposeAsync(); } @@ -711,12 +711,12 @@ public sealed class HistoryUpdateTests : IDisposable var (rawResults, rawErrors) = Call(nm, identity, typeof(DeleteRawModifiedDetails), new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = T1, EndTime = T2, IsDeleteModified = false }); var (atResults, atErrors) = Call(nm, identity, typeof(DeleteAtTimeDetails), - new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = new DateTimeCollection { T1 } }); + new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = new List { T1 } }); foreach (var (r, e) in new[] { (results, errors), (rawResults, rawErrors), (atResults, atErrors) }) { - e[0].StatusCode.Code.ShouldBe(StatusCodes.BadInvalidState); - r[0].StatusCode.Code.ShouldBe(StatusCodes.BadInvalidState); + e[0].StatusCode.Code.ShouldBe(StatusCodes.BadInvalidState.Code); + r[0].StatusCode.Code.ShouldBe(StatusCodes.BadInvalidState.Code); } consulted.ShouldBe(3); @@ -747,8 +747,8 @@ public sealed class HistoryUpdateTests : IDisposable var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d)); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); - results[0].OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); + results[0].OperationResults.ToArray()!.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted.Code]); updater.UpdateCalls.ShouldHaveSingleItem().Tag.ShouldBe(Tagname); consulted.ShouldBe(1); } @@ -775,7 +775,7 @@ public sealed class HistoryUpdateTests : IDisposable var (results, _) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.WriteOperate), nodeId, Value(T1, 1d)); - results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); consulted.ShouldBe(0); updater.UpdateCalls.ShouldBeEmpty(); } @@ -805,13 +805,13 @@ public sealed class HistoryUpdateTests : IDisposable var deleteRaw = Call(nm, identity, typeof(DeleteRawModifiedDetails), new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = T1, EndTime = T2, IsDeleteModified = false }); var deleteAt = Call(nm, identity, typeof(DeleteAtTimeDetails), - new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = new DateTimeCollection { T1 } }); + new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = new List { T1 } }); foreach (var (results, errors) in new[] { update, deleteRaw, deleteAt }) { - errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); - results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); - (results[0].OperationResults?.Count ?? 0).ShouldBe(0); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); + results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); + (results[0].OperationResults.Count).ShouldBe(0); } } finally { await host.DisposeAsync(); } @@ -842,14 +842,14 @@ public sealed class HistoryUpdateTests : IDisposable var (modResults, modErrors) = Call(nm, identity, typeof(DeleteRawModifiedDetails), new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = T1, EndTime = T2, IsDeleteModified = true }); - removeErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); - removeResults[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); - modErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); - modResults[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + removeErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); + removeResults[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); + modErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); + modResults[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); consulted.ShouldBe(0); var (_, servableErrors) = Insert(nm, identity, nodeId, Value(T1, 1d)); - servableErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadInvalidState); + servableErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadInvalidState.Code); consulted.ShouldBe(1); updater.UpdateCalls.ShouldBeEmpty(); } @@ -883,8 +883,8 @@ public sealed class HistoryUpdateTests : IDisposable var (results, _) = Update(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, PerformUpdateType.Update, LocalValue(justPast, 1d), LocalValue(justInside, 2d)); - results[0].OperationResults.Select(c => c.Code) - .ShouldBe([StatusCodes.BadOutOfRange, StatusCodes.GoodEntryInserted]); + results[0].OperationResults.ToArray()!.Select(c => c.Code) + .ShouldBe([StatusCodes.BadOutOfRange.Code, StatusCodes.GoodEntryInserted.Code]); var sent = updater.UpdateCalls.ShouldHaveSingleItem().Values.ShouldHaveSingleItem(); sent.TimestampUtc.ShouldBe(bound.AddTicks(-1)); sent.TimestampUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc); @@ -894,15 +894,14 @@ public sealed class HistoryUpdateTests : IDisposable private static DataValue LocalValue(DateTime local, double value) { - var dv = new DataValue(new Variant(value)) { StatusCode = StatusCodes.Good }; - dv.SourceTimestamp = local; + var dv = new DataValue(new Variant(value), StatusCodes.Good, local); return dv; } // ───────────────────────────── helpers ───────────────────────────── private static DataValue Value(DateTime timestamp, object value) => - new(new Variant(value)) { SourceTimestamp = timestamp, StatusCode = StatusCodes.Good }; + new(SdkValueBoundary.FromClr(value), StatusCodes.Good, timestamp); private static NodeId Historized(OtOpcUaNodeManager nm, AddressSpaceRealm realm, string id) { @@ -922,7 +921,7 @@ public sealed class HistoryUpdateTests : IDisposable { NodeId = nodeId, PerformInsertReplace = type, - UpdateValues = new DataValueCollection(values), + UpdateValues = new List(values), }); /// Drives the SDK's PUBLIC HistoryUpdate entry point (base admission + Lock + dispatch). @@ -930,7 +929,7 @@ public sealed class HistoryUpdateTests : IDisposable OtOpcUaNodeManager nm, IUserIdentity identity, Type detailsType, HistoryUpdateDetails details) { var context = new OperationContext( - new RequestHeader(), secureChannelContext: null, RequestType.HistoryUpdate, identity); + new RequestHeader(), secureChannelContext: null, RequestType.HistoryUpdate, requestLifetime: RequestLifetime.None, identity); var nodesToUpdate = new List { details }; var results = new List { null! }; var errors = new List { null! }; @@ -988,7 +987,7 @@ public sealed class HistoryUpdateTests : IDisposable public List<(string Tag, DateTime Start, DateTime End)> DeleteRawCalls { get; } = []; public List> DeleteAtTimeCalls { get; } = []; public uint[]? UpdateCodes { get; init; } - public uint DeleteRawCode { get; init; } = StatusCodes.Good; + public uint DeleteRawCode { get; init; } = StatusCodes.Good.Code; public uint[]? DeleteAtTimeCodes { get; init; } public Exception? Throw { get; init; } @@ -999,7 +998,7 @@ public sealed class HistoryUpdateTests : IDisposable await Task.Delay(1, ct).ConfigureAwait(false); if (Throw is not null) throw Throw; lock (UpdateCalls) { UpdateCalls.Add((tag, mode, values.ToList())); Callers.Add(context); } - return UpdateCodes ?? Enumerable.Repeat(StatusCodes.GoodEntryInserted, values.Count).ToArray(); + return UpdateCodes ?? Enumerable.Repeat(StatusCodes.GoodEntryInserted.Code, values.Count).ToArray(); } public async Task DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, HistoryUpdateContext context, @@ -1017,7 +1016,7 @@ public sealed class HistoryUpdateTests : IDisposable await Task.Delay(1, ct).ConfigureAwait(false); if (Throw is not null) throw Throw; lock (DeleteAtTimeCalls) { DeleteAtTimeCalls.Add(timesUtc.ToList()); lock (UpdateCalls) Callers.Add(context); } - return DeleteAtTimeCodes ?? Enumerable.Repeat(StatusCodes.Good, timesUtc.Count).ToArray(); + return DeleteAtTimeCodes ?? Enumerable.Repeat(StatusCodes.Good.Code, timesUtc.Count).ToArray(); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/HistoryPagingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/HistoryPagingTests.cs index aeab3c5e..66d10d82 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/HistoryPagingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/HistoryPagingTests.cs @@ -15,7 +15,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; public sealed class HistoryPagingTests { private static DataValueSnapshot Sample(double v, DateTime src) => - new(v, StatusCodes.Good, src, src); + new(v, StatusCodes.Good.Code, src, src); // --- IsFullPage ----------------------------------------------------------------------------- @@ -89,7 +89,7 @@ public sealed class HistoryPagingTests [Fact] public void ComputeResumeCursor_null_source_timestamp_falls_back_to_min_value() { - var page = new[] { new DataValueSnapshot(1.0, StatusCodes.Bad, null, DateTime.UtcNow) }; + var page = new[] { new DataValueSnapshot(1.0, StatusCodes.Bad.Code, null, DateTime.UtcNow) }; HistoryPaging.ComputeResumeCursor(page, out var nextStart, out var skip); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/HistoryServerCapabilitiesAdvertisementTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/HistoryServerCapabilitiesAdvertisementTests.cs index 0aaca5b1..30654125 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/HistoryServerCapabilitiesAdvertisementTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/HistoryServerCapabilitiesAdvertisementTests.cs @@ -14,15 +14,15 @@ public sealed class HistoryServerCapabilitiesAdvertisementTests(BoundsServerFixt { private async Task ReadAdvertisedAsync() { - var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.Read, identity: null); - var nodesToRead = new ReadValueIdCollection + var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.Read, requestLifetime: RequestLifetime.None, identity: null); + var nodesToRead = new List { new ReadValueId { NodeId = VariableIds.HistoryServerCapabilities_MaxReturnDataValues, AttributeId = Attributes.Value }, }; var (values, _) = await fx.Nm.Server.NodeManager.ReadAsync(context, 0, TimestampsToReturn.Neither, nodesToRead, TestContext.Current.CancellationToken); values.Count.ShouldBe(1); StatusCode.IsGood(values[0].StatusCode).ShouldBeTrue(values[0].StatusCode.ToString()); - return (uint)values[0].Value; + return (uint)SdkValueBoundary.ToClr(values[0].WrappedValue)!; } [Fact] diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAclEnforcementTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAclEnforcementTests.cs index a81e663d..c05a5969 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAclEnforcementTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAclEnforcementTests.cs @@ -54,7 +54,7 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable var (_, errors) = ReadRaw(nm, identity: null, nodeId); - errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied); + errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied.Code); } finally { await host.DisposeAsync(); } } @@ -81,10 +81,10 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable var (results, errors) = ReadRaw(nm, Identity(), nodeId); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); // Not BadHistoryOperationUnsupported: "you may not read this" and "this node has no history" // are different facts, and reporting the wrong one sends an operator debugging historisation. - results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); } finally { await host.DisposeAsync(); } } @@ -106,16 +106,16 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable nm.Authorizer = Authorizer(enabled: true, GrantOn(NodePermissions.Read)); var (deniedResults, deniedErrors) = ReadRaw(nm, Identity(), modified: true, nodeId); - deniedErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); - deniedResults[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + deniedErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); + deniedResults[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); nm.Authorizer = Authorizer(enabled: true, GrantOn(NodePermissions.HistoryRead)); var (allowedResults, allowedErrors) = ReadRaw(nm, Identity(), modified: true, nodeId); // A real trail entry must come back: Null's BadHistoryOperationUnsupported is also what the SDK pre-seeds, // so asserting it would pass even if the grant never reached the modified branch. - allowedErrors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); - allowedResults[0].StatusCode.Code.ShouldBe(StatusCodes.Good); - var data = ExtensionObject.ToEncodeable(allowedResults[0].HistoryData).ShouldBeOfType(); + allowedErrors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); + allowedResults[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); + var data = ExtensionObject.ToEncodeable(allowedResults[0].HistoryData)!.ShouldBeOfType(); data.DataValues.Count.ShouldBe(1); data.ModificationInfos[0].UserName.ShouldBe("alice"); } @@ -136,7 +136,7 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable var (_, errors) = ReadRaw(nm, Identity(), nodeId); - errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied); + errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied.Code); } finally { await host.DisposeAsync(); } } @@ -177,8 +177,8 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable var (_, errors) = ReadRaw(nm, Identity(), allowed, denied); - errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied); - errors[1].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied.Code); + errors[1].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); } finally { await host.DisposeAsync(); } } @@ -235,7 +235,7 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable var errors = Write(nm, Identity(), nodeId, 42f); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); gateway.Writes.ShouldHaveSingleItem(); } finally { await host.DisposeAsync(); } @@ -260,7 +260,7 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable var errors = Write(nm, Identity(), nodeId, 42f); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); gateway.Writes.ShouldBeEmpty(); } finally { await host.DisposeAsync(); } @@ -284,7 +284,7 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable var errors = Write(nm, Identity(), nodeId, 42f); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); gateway.Writes.ShouldBeEmpty(); } finally { await host.DisposeAsync(); } @@ -303,7 +303,7 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable OtOpcUaNodeManager nm, IUserIdentity? identity, NodeId nodeId, object value) { var context = new OperationContext( - new RequestHeader(), secureChannelContext: null, RequestType.Write, identity); + new RequestHeader(), secureChannelContext: null, RequestType.Write, requestLifetime: RequestLifetime.None, identity); var writes = new List { @@ -311,7 +311,7 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable { NodeId = nodeId, AttributeId = Attributes.Value, - Value = new DataValue(new Variant(value)) { StatusCode = StatusCodes.Good }, + Value = new DataValue(SdkValueBoundary.FromClr(value), StatusCodes.Good), }, }; var errors = Enumerable.Repeat(null!, writes.Count).ToList(); @@ -341,7 +341,7 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable CancellationToken cancellationToken) { var t = DateTime.UtcNow.AddMinutes(-5); - var prior = new DataValueSnapshot(1.5, StatusCodes.Good, t, t); + var prior = new DataValueSnapshot(1.5, StatusCodes.Good.Code, t, t); return Task.FromResult(new HistoryModifiedReadResult( [new HistoryModifiedValue(prior, DateTime.UtcNow, HistoryModificationType.Replace, "alice", 1, t)], false)); } @@ -374,7 +374,7 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable OtOpcUaNodeManager nm, IUserIdentity? identity, bool modified, params NodeId[] nodeIds) { var context = new OperationContext( - new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity); + new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity); var nodesToRead = nodeIds.Select(id => new HistoryReadValueId { NodeId = id }).ToList(); var results = Enumerable.Repeat(null!, nodeIds.Length).ToList(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmSourceFieldsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmSourceFieldsTests.cs index 687ddc0f..8373bd8f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmSourceFieldsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmSourceFieldsTests.cs @@ -62,16 +62,16 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable // EventType — the concrete condition type, so a client reading the FIELD (not just an OfType // where-clause) can resolve the type. condition.EventType.ShouldNotBeNull(); - condition.EventType.Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType); + condition.EventType!.Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType); // SourceNode — the condition's own NodeId (it IS the source: no separate value variable exists // for an alarm-bearing raw tag). Equal to ConditionId. condition.SourceNode.ShouldNotBeNull(); - condition.SourceNode.Value.ShouldBe(new NodeId(RawAlarmPath, rawNs)); + condition.SourceNode!.Value.ShouldBe(new NodeId(RawAlarmPath, rawNs)); // SourceName — the RawPath: unique across devices, matching ConditionId/SourceNode. condition.SourceName.ShouldNotBeNull(); - condition.SourceName.Value.ShouldBe(RawAlarmPath); + condition.SourceName!.Value.ShouldBe(RawAlarmPath); // The leaf name is NOT lost — it stays on ConditionName. condition.ConditionName!.Value.ShouldBe("HR200"); @@ -169,11 +169,11 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable condition.ShouldNotBeNull(); condition.ConditionClassId.ShouldNotBeNull(); - condition.ConditionClassId.Value.ShouldBe(ObjectTypeIds.BaseConditionClassType); + condition.ConditionClassId!.Value.ShouldBe(ObjectTypeIds.BaseConditionClassType); condition.ConditionClassName.ShouldNotBeNull(); - condition.ConditionClassName.Value.ShouldNotBeNull(); - condition.ConditionClassName.Value.Text.ShouldBe("BaseConditionClass"); + condition.ConditionClassName!.Value.IsNull.ShouldBeFalse(); + condition.ConditionClassName!.Value.Text.ShouldBe("BaseConditionClass"); } /// #475 — a SCRIPTED condition (UNS realm) carries the SAME classification fields: native and scripted @@ -213,8 +213,8 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw); condition.ShouldNotBeNull(); condition.Quality.ShouldNotBeNull(); - condition.Quality.Value.ShouldBe((StatusCode)StatusCodes.BadWaitingForInitialData); - StatusCode.IsGood(condition.Quality.Value).ShouldBeFalse(); + condition.Quality!.Value.ShouldBe((StatusCode)StatusCodes.BadWaitingForInitialData); + StatusCode.IsGood(condition.Quality!.Value).ShouldBeFalse(); } /// #477 — a SCRIPTED condition is script-computed and always "live" in v1, so it materialises Good. @@ -233,7 +233,7 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable var condition = nm.TryGetAlarmCondition("scripted-q", AddressSpaceRealm.Uns); condition.ShouldNotBeNull(); condition.Quality.ShouldNotBeNull(); - StatusCode.IsGood(condition.Quality.Value).ShouldBeTrue(); + StatusCode.IsGood(condition.Quality!.Value).ShouldBeTrue(); } /// #477 — WriteAlarmCondition projects the snapshot's Quality onto the live condition node, so a @@ -258,12 +258,12 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw); condition.ShouldNotBeNull(); - StatusCode.IsBad(condition.Quality.Value).ShouldBeTrue(); + StatusCode.IsBad(condition.Quality!.Value).ShouldBeTrue(); // Recover: a Good snapshot restores Good quality. var goodSnapshot = badSnapshot with { StatusCode = OpcUaStatus.Good }; nm.WriteAlarmCondition(RawAlarmPath, goodSnapshot, DateTime.UtcNow, AddressSpaceRealm.Raw); - StatusCode.IsGood(condition.Quality.Value).ShouldBeTrue(); + StatusCode.IsGood(condition.Quality!.Value).ShouldBeTrue(); } /// #477 Layer 2 — the dedicated connectivity-quality path sets ONLY the condition's Quality @@ -283,7 +283,7 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw); condition.ShouldNotBeNull(); var severityBefore = condition.Severity!.Value; - var messageBefore = condition.Message!.Value?.Text; + var messageBefore = condition.Message!.Value.Text; // Device comes online → Good. nm.WriteAlarmQuality(RawAlarmPath, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); @@ -291,9 +291,9 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable // Device goes comms-lost → Bad, but severity / message / active-ack state are untouched. nm.WriteAlarmQuality(RawAlarmPath, OpcUaStatus.Bad, DateTime.UtcNow, AddressSpaceRealm.Raw); - StatusCode.IsBad(condition.Quality.Value).ShouldBeTrue(); - condition.Severity.Value.ShouldBe(severityBefore); - condition.Message.Value?.Text.ShouldBe(messageBefore); + StatusCode.IsBad(condition.Quality!.Value).ShouldBeTrue(); + condition.Severity!.Value.ShouldBe(severityBefore); + condition.Message!.Value.Text.ShouldBe(messageBefore); condition.ActiveState!.Id!.Value.ShouldBeFalse(); // unchanged: annotation only } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerArrayTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerArrayTests.cs index f5040c2d..9a25ee5d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerArrayTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerArrayTests.cs @@ -37,7 +37,7 @@ public sealed class NodeManagerArrayTests : IDisposable var variable = nm.TryGetVariable("eq-1/arr"); variable.ShouldNotBeNull(); variable!.ValueRank.ShouldBe(ValueRanks.OneDimension); - variable.ArrayDimensions.ShouldNotBeNull(); + variable.ArrayDimensions.IsNull.ShouldBeFalse(); variable.ArrayDimensions.ShouldBe(new uint[] { 8u }); await host.DisposeAsync(); @@ -57,7 +57,7 @@ public sealed class NodeManagerArrayTests : IDisposable var variable = nm.TryGetVariable("eq-1/arr-unfixed"); variable.ShouldNotBeNull(); variable!.ValueRank.ShouldBe(ValueRanks.OneDimension); - variable.ArrayDimensions.ShouldNotBeNull(); + variable.ArrayDimensions.IsNull.ShouldBeFalse(); variable.ArrayDimensions.ShouldBe(new uint[] { 0u }); await host.DisposeAsync(); @@ -77,7 +77,7 @@ public sealed class NodeManagerArrayTests : IDisposable var variable = nm.TryGetVariable("eq-1/scalar"); variable.ShouldNotBeNull(); variable!.ValueRank.ShouldBe(ValueRanks.Scalar); - variable.ArrayDimensions.ShouldBeNull(); + variable.ArrayDimensions.IsNull.ShouldBeTrue(); await host.DisposeAsync(); } @@ -98,7 +98,7 @@ public sealed class NodeManagerArrayTests : IDisposable var variable = nm.TryGetVariable("eq-1/arrwrite"); variable.ShouldNotBeNull(); - variable!.Value.ShouldBe(payload); + SdkValueBoundary.ToClr(variable!.Value).ShouldBe(payload); variable.StatusCode.ShouldBe((StatusCode)StatusCodes.Good); await host.DisposeAsync(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerBatchWriteTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerBatchWriteTests.cs index 92233d32..ace632e8 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerBatchWriteTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerBatchWriteTests.cs @@ -66,7 +66,7 @@ public sealed class NodeManagerBatchWriteTests : IDisposable var b = nm.TryGetVariable("eq-1/b"); b.ShouldNotBeNull(); - b!.Value.ShouldBeNull(); + b!.Value.IsNull.ShouldBeTrue(); b.StatusCode.ShouldBe((StatusCode)StatusCodes.BadNoCommunication); b.StatusCode.ShouldNotBe((StatusCode)StatusCodes.Bad, "the batched path must hand the status sub-code to the SDK verbatim (#519)"); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs index 4875ac61..e5142f0c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs @@ -20,7 +20,7 @@ public sealed class BoundsServerFixture : IAsyncLifetime public OtOpcUaNodeManager Nm { get; private set; } = null!; /// The historized variable every case reads. - public NodeId NodeId { get; private set; } = null!; + public NodeId NodeId { get; private set; } = default; /// Serialises the cases (they share the node manager's source). public SemaphoreSlim Gate { get; } = new(1, 1); @@ -79,7 +79,7 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) : /// Part 11 Table 1's data: value = minutes past 5:00. private static DataValueSnapshot[] TableData() => new[] { "5:00", "5:02", "5:03", "5:05", "5:06" } - .Select(s => new DataValueSnapshot((double)(T(s) - T("5:00")).TotalMinutes, StatusCodes.Good, T(s), T(s))) + .Select(s => new DataValueSnapshot((double)(T(s) - T("5:00")).TotalMinutes, StatusCodes.Good.Code, T(s), T(s))) .ToArray(); public enum Shape { Reverse, ForwardOnly, Microsecond } @@ -170,7 +170,7 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) : var source = Source(shape, TableData()); var (values, error, _) = await ReadAsync(source, s, e, max, bounds, cp: null); - error.StatusCode.Code.ShouldBe(StatusCodes.Good); + error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); Render(values, s, e).ShouldBe(expected); (source as SeriesSource)?.ReverseWindowsOnForwardOnly.ShouldBe(0); } @@ -228,10 +228,10 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) : public async Task A_bad_sample_is_returned_as_the_bound_as_stored(Shape shape) { var series = TableData(); - series[0] = new DataValueSnapshot(null, StatusCodes.BadNoCommunication, T("5:00"), T("5:00")); + series[0] = new DataValueSnapshot(null, StatusCodes.BadNoCommunication.Code, T("5:00"), T("5:00")); var (values, _, _) = await ReadAsync(Source(shape, series), T("5:01"), T("5:04"), 0, true, cp: null); - values[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication); + values[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication.Code); values[0].SourceTimestamp.ShouldBe(T("5:00")); values.Count.ShouldBe(4); } @@ -248,19 +248,19 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) : public async Task Tie_clusters_on_either_side_yield_one_bound_each(Shape shape) { var series = new List(); - void Add(string t, double v) => series.Add(new DataValueSnapshot(v, StatusCodes.Good, T(t), T(t))); + void Add(string t, double v) => series.Add(new DataValueSnapshot(v, StatusCodes.Good.Code, T(t), T(t))); Add("5:00", 1); Add("5:00", 2); Add("5:00", 3); Add("5:02", 4); Add("5:05", 5); Add("5:05", 6); var (fwd, _, _) = await ReadAsync(Source(shape, series), T("5:01"), T("5:04"), 0, true, cp: null); - fwd.Select(v => (double)v.Value).ShouldBe([3d, 4d, 5d]); + fwd.Select(v => (double)SdkValueBoundary.ToClr(v.WrappedValue)!).ShouldBe([3d, 4d, 5d]); var (rev, _, _) = await ReadAsync(Source(shape, series), T("5:04"), T("5:01"), 0, true, cp: null); - rev.Select(v => (double)v.Value).ShouldBe([5d, 4d, 3d]); + rev.Select(v => (double)SdkValueBoundary.ToClr(v.WrappedValue)!).ShouldBe([5d, 4d, 3d]); var (onStart, _, _) = await ReadAsync(Source(shape, series), T("5:00"), T("5:03"), 0, true, cp: null); - onStart.Select(v => (double)v.Value).ShouldBe([1d, 2d, 3d, 4d, 5d]); + onStart.Select(v => (double)SdkValueBoundary.ToClr(v.WrappedValue)!).ShouldBe([1d, 2d, 3d, 4d, 5d]); } /// @@ -273,7 +273,7 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) : var series = TableData(); var (values, _, _) = await ReadAsync(Source(Shape.Microsecond, series), T("5:00").AddTicks(7), T("5:04"), 0, true, cp: null); - values.Select(v => (double)v.Value).ShouldBe([0d, 2d, 3d, 5d]); + values.Select(v => (double)SdkValueBoundary.ToClr(v.WrappedValue)!).ShouldBe([0d, 2d, 3d, 5d]); } /// @@ -303,14 +303,14 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) : { var source = new SeriesSource(TableData(), reverse: true, inclusiveForwardEnd: false, resolution: 1, ceiling: 4); var (first, error, cp) = await ReadAsync(source, T("5:01"), T("5:06"), max: 10, bounds: true, cp: null); - error.StatusCode.Code.ShouldBe(StatusCodes.Good); + error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); // Start bound 5:00 + the three data rows the ceiling allows = the page's effective cap, exactly. Render(first, T("5:01"), T("5:06")).ShouldBe("5:00, 5:02, 5:03, 5:05"); cp.ShouldNotBeNull("the page is full, so the end bound is owed on the next page"); var (second, error2, cp2) = await ReadAsync(source, T("5:01"), T("5:06"), max: 10, bounds: true, cp); - error2.StatusCode.Code.ShouldBe(StatusCodes.Good); + error2.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); Render(second, T("5:01"), T("5:06")).ShouldBe("5:06"); cp2.ShouldBeNull(); @@ -353,7 +353,7 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) : do { var (values, error, next) = await ReadAsync(capped, T(start), T(end), 0, true, cp); - error.StatusCode.Code.ShouldBe(StatusCodes.Good, $"L={ceiling} ties={ties} {start}->{end}"); + error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code, $"L={ceiling} ties={ties} {start}->{end}"); values.Count.ShouldBeLessThanOrEqualTo(ceiling, $"L={ceiling} ties={ties} {start}->{end} page {pages}"); all.AddRange(values); cp = next; @@ -366,7 +366,7 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) : } } - static DataValueSnapshot Snap(string at, double v) => new(v, StatusCodes.Good, T(at), T(at)); + static DataValueSnapshot Snap(string at, double v) => new(v, StatusCodes.Good.Code, T(at), T(at)); } /// @@ -391,8 +391,8 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) : var (values, error, cp) = await ReadResultAsync(Source(shape, []), s, e, n); values.ShouldBeEmpty($"{start}->{end} by {n}"); cp.ShouldBeNull(); - error.StatusCode.Code.ShouldBe(StatusCodes.Good); - LastNodeStatus.Code.ShouldBe(StatusCodes.GoodNoData, $"{start}->{end} by {n}"); + error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); + LastNodeStatus.Code.ShouldBe(StatusCodes.GoodNoData.Code, $"{start}->{end} by {n}"); } } @@ -417,7 +417,7 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) : Render(values, T("5:01"), T("5:07")).ShouldBe("5:02, 5:03, 5:05, 5:06"); var (_, modErr, _) = Read(T("5:00"), T("5:05"), 0, bounds: true, cp: null, modified: true); - modErr.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported, "no trail on this fake — not InvalidArgument"); + modErr.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code, "no trail on this fake — not InvalidArgument"); } finally { @@ -443,7 +443,7 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) : { fx.Nm.HistorianDataSource = Source(Shape.Reverse, TableData()); var (_, error, cp) = Read(T("5:00"), T("5:05"), 0, bounds: true, cp: null, modified: true); - error.StatusCode.Code.ShouldBe(StatusCodes.BadInvalidArgument); + error.StatusCode.Code.ShouldBe(StatusCodes.BadInvalidArgument.Code); cp.ShouldBeNull(); } finally { fx.Gate.Release(); } @@ -459,19 +459,19 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) : var v = values[i]; if (v.StatusCode.Code != StatusCodes.BadBoundNotFound) { - v.Value.ShouldNotBeNull(); - parts.Add($"{v.SourceTimestamp.Hour}:{v.SourceTimestamp.Minute:00}"); + SdkValueBoundary.ToClr(v.WrappedValue)!.ShouldNotBeNull(); + parts.Add($"{v.SourceTimestamp.ToDateTime().Hour}:{v.SourceTimestamp.ToDateTime().Minute:00}"); continue; } - v.Value.ShouldBeNull("a missing bound carries a null value"); + SdkValueBoundary.ToClr(v.WrappedValue)!.ShouldBeNull("a missing bound carries a null value"); var isFirst = i == 0 && start != Unspecified && v.SourceTimestamp == start; var isLast = end != Unspecified && v.SourceTimestamp == end; if (isFirst) { parts.Add("FIRST"); continue; } if (isLast) { parts.Add("LAST"); continue; } // Open side (footnotes a/b): one second from the previous returned value. i.ShouldBeGreaterThan(0); - var prev = values[i - 1].SourceTimestamp; + var prev = values[i - 1].SourceTimestamp.ToDateTime(); if (v.SourceTimestamp == prev.AddSeconds(1)) parts.Add("LAST+"); else if (v.SourceTimestamp == prev.AddSeconds(-1)) parts.Add("FIRST-"); else parts.Add($"?{v.SourceTimestamp:O}"); @@ -488,7 +488,7 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) : do { var (values, error, next) = await ReadAsync(source, start, end, max, bounds, cp); - error.StatusCode.Code.ShouldBe(StatusCodes.Good); + error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); if (max > 0) values.Count.ShouldBeLessThanOrEqualTo((int)max, "bounds count toward NumValuesPerNode"); all.AddRange(values); cp = next; @@ -521,21 +521,21 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) : private (IReadOnlyList Values, ServiceResult Error, byte[]? Cp) Read( DateTime start, DateTime end, uint max, bool bounds, byte[]? cp, bool modified) { - var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null); + var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null); var details = new ReadRawModifiedDetails { StartTime = start, EndTime = end, NumValuesPerNode = max, ReturnBounds = bounds, IsReadModified = modified, }; - var nodesToRead = new List { new() { NodeId = fx.NodeId, ContinuationPoint = cp } }; + var nodesToRead = new List { new() { NodeId = fx.NodeId, ContinuationPoint = ByteString.From(cp) } }; var results = new List { null! }; var errors = new List { null! }; fx.Nm.HistoryRead(context, details, TimestampsToReturn.Both, releaseContinuationPoints: false, nodesToRead, results, errors); LastNodeStatus = results[0]?.StatusCode ?? StatusCodes.Bad; var values = new List(); - if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho)) - values.AddRange(((HistoryData)ExtensionObject.ToEncodeable(ho)).DataValues); - return (values, errors[0], results[0]?.ContinuationPoint); + if (results[0]?.HistoryData is { } ho && !ho.IsNull) + values.AddRange(((HistoryData)ExtensionObject.ToEncodeable(ho)!).DataValues); + return (values, errors[0], results[0]?.ContinuationPoint.ToNullableArray()); } /// diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadConcurrencyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadConcurrencyTests.cs index 22a64e41..4de25308 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadConcurrencyTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadConcurrencyTests.cs @@ -111,7 +111,7 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable // Node index 3's tagname throws; all others return a single good sample. var fake = new PerTagnameFake(tagname => tagname == "WW.Tag3" ? throw new InvalidOperationException("backend boom for Tag3") - : new HistorianRead(new[] { new DataValueSnapshot(1.0f, StatusCodes.Good, src, srv) }, null)); + : new HistorianRead(new[] { new DataValueSnapshot(1.0f, StatusCodes.Good.Code, src, srv) }, null)); nm.HistorianDataSource = fake; var nodeIds = MaterializeHistorizedNodes(nm, count: 8); @@ -132,12 +132,12 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable if (i == 3) { StatusCode.IsBad(errors[i].StatusCode).ShouldBeTrue(); - errors[i].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + errors[i].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); } else { - errors[i].StatusCode.Code.ShouldBe(StatusCodes.Good); - results[i].StatusCode.Code.ShouldBe(StatusCodes.Good); + errors[i].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); + results[i].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); } } @@ -171,7 +171,7 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable .WaitAsync(TimeSpan.FromSeconds(10), Ct); for (var i = 0; i < 4; i++) - errors[i].StatusCode.Code.ShouldBe(StatusCodes.BadTimeout); + errors[i].StatusCode.Code.ShouldBe(StatusCodes.BadTimeout.Code); await host.DisposeAsync(); } @@ -212,7 +212,7 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable // Second concurrent batch: the limiter is saturated → all handles BadTooManyOperations. var (_, errors2) = await Task.Run(() => InvokeHistoryRead(server, nm, details, ids[1]), Ct) .WaitAsync(TimeSpan.FromSeconds(5), Ct); - errors2[0].StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations); + errors2[0].StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations.Code); fake.Entered.ShouldBe(1, "the rejected batch must never reach the backend"); // Release the parked batch; the slot frees and a later read succeeds. @@ -221,7 +221,7 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable var (_, errors3) = await Task.Run(() => InvokeHistoryRead(server, nm, details, ids[1]), Ct) .WaitAsync(TimeSpan.FromSeconds(10), Ct); - errors3[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + errors3[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); await host.DisposeAsync(); } @@ -246,7 +246,7 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable OtOpcUaSdkServer server, OtOpcUaNodeManager nm, HistoryReadDetails details, params NodeId[] nodeIds) { var context = new OperationContext( - new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null); + new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null); var nodesToRead = nodeIds.Select(id => new HistoryReadValueId { NodeId = id }).ToList(); var results = Enumerable.Repeat(null!, nodeIds.Length).ToList(); @@ -290,7 +290,7 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable } return new HistorianRead( - new[] { new DataValueSnapshot(1.0f, StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow) }, null); + new[] { new DataValueSnapshot(1.0f, StatusCodes.Good.Code, DateTime.UtcNow, DateTime.UtcNow) }, null); } private void UpdateMax(int observed) @@ -384,7 +384,7 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable // a saturated limiter (rather than the first batch's own deadline freeing the slot early). await _gate.Task.ConfigureAwait(false); return new HistorianRead( - new[] { new DataValueSnapshot(1.0f, StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow) }, null); + new[] { new DataValueSnapshot(1.0f, StatusCodes.Good.Code, DateTime.UtcNow, DateTime.UtcNow) }, null); } public Task ReadProcessedAsync( diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsHorizonTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsHorizonTests.cs index 72b59732..1c3617b2 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsHorizonTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsHorizonTests.cs @@ -38,7 +38,7 @@ public sealed class NodeManagerHistoryReadEventsHorizonTests : IDisposable try { var r = ReadEvents(nm, node, max: 10, start: DateTime.MinValue, end: Now); - r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good); + r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); r.Ids.ShouldBe(["r4", "r3", "r2", "r1", "r0"]); gw.Requests.Min(q => q.Start).ShouldBe(DateTime.UnixEpoch); gw.BadRequests.ShouldBe(0); @@ -54,7 +54,7 @@ public sealed class NodeManagerHistoryReadEventsHorizonTests : IDisposable { nm.EventReadHorizon = Horizon; var r = ReadEvents(nm, node, max: 10, start: DateTime.MinValue, end: Now); - r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good); + r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); r.Ids.ShouldBe(["r4", "r3", "r2", "r1", "r0"]); r.Cp.ShouldBeNull(); // Requests are rounded outward to the millisecond, so the earliest one may start up to 1 ms before the floor. @@ -95,12 +95,12 @@ public sealed class NodeManagerHistoryReadEventsHorizonTests : IDisposable { nm.EventReadHorizon = Horizon; var forward = ReadEvents(nm, node, max: 10, start: Floor.AddDays(-20), end: Floor.AddDays(-1)); - forward.Error.StatusCode.Code.ShouldBe(StatusCodes.Good); - forward.Status.Code.ShouldBe(StatusCodes.GoodNoData); + forward.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); + forward.Status.Code.ShouldBe(StatusCodes.GoodNoData.Code); forward.Ids.ShouldBeEmpty(); var reverse = ReadEvents(nm, node, max: 10, start: Floor.AddDays(-1), end: Floor.AddDays(-20)); - reverse.Status.Code.ShouldBe(StatusCodes.GoodNoData); + reverse.Status.Code.ShouldBe(StatusCodes.GoodNoData.Code); gw.Requests.ShouldBeEmpty(); } @@ -122,7 +122,7 @@ public sealed class NodeManagerHistoryReadEventsHorizonTests : IDisposable do { var r = ReadEvents(nm, node, max: 10, start: DateTime.MinValue, end: Now, inboundCp: cp); - r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good); + r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); all.AddRange(r.Ids); cp = r.Cp; (++pages).ShouldBeLessThan(10); @@ -167,23 +167,23 @@ public sealed class NodeManagerHistoryReadEventsHorizonTests : IDisposable private static (List Ids, ServiceResult Error, StatusCode Status, byte[]? Cp) ReadEvents( OtOpcUaNodeManager nm, NodeId node, uint max, DateTime start, DateTime end, byte[]? inboundCp = null) { - var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null); + var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null); var filter = new EventFilter(); - filter.SelectClauses.Add(new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("EventId")) + filter.SelectClauses = [new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("EventId")) { AttributeId = Attributes.Value, - }); + }]; var details = new ReadEventDetails { StartTime = start, EndTime = end, NumValuesPerNode = max, Filter = filter }; - var nodesToRead = new List { new() { NodeId = node, ContinuationPoint = inboundCp } }; + var nodesToRead = new List { new() { NodeId = node, ContinuationPoint = ByteString.From(inboundCp) } }; var results = new List { null! }; var errors = new List { null! }; nm.HistoryRead(context, details, TimestampsToReturn.Both, false, nodesToRead, results, errors); var ids = new List(); - if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho)) - foreach (var e in ((HistoryEvent)ExtensionObject.ToEncodeable(ho)).Events) - ids.Add(System.Text.Encoding.UTF8.GetString((byte[])e.EventFields[0].Value)); - return (ids, errors[0], results[0]?.StatusCode ?? StatusCodes.Good, results[0]?.ContinuationPoint); + if (results[0]?.HistoryData is { } ho && !ho.IsNull) + foreach (var e in ((HistoryEvent)ExtensionObject.ToEncodeable(ho)!).Events) + ids.Add(System.Text.Encoding.UTF8.GetString((byte[])SdkValueBoundary.ToClr(e.EventFields[0])!)); + return (ids, errors[0], results[0]?.StatusCode ?? StatusCodes.Good, results[0]?.ContinuationPoint.ToNullableArray()); } /// The Gateway's SQL event read: forward only, [start, end] inclusive, time-ordered, capped + flag. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsPagingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsPagingTests.cs index 7d4ec80f..c49448a8 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsPagingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsPagingTests.cs @@ -69,7 +69,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable var p1 = ReadEvents(nm, node, max: 3, inboundCp: null); p1.Cp.ShouldNotBeNull(); var p2 = ReadEvents(nm, node, max: 3, inboundCp: p1.Cp); - p2.Error.StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations); + p2.Error.StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations.Code); await host.DisposeAsync(); } @@ -78,7 +78,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable { var (host, nm, node, _) = await SetupAsync(Distinct(5)); var r = ReadEvents(nm, node, max: 2, inboundCp: Guid.NewGuid().ToByteArray()); - r.Error.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid); + r.Error.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid.Code); await host.DisposeAsync(); } @@ -90,7 +90,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable p1.Cp.ShouldNotBeNull(); InvokeRelease(nm, node, p1.Cp!); ReadEvents(nm, node, max: 4, inboundCp: p1.Cp).Error.StatusCode.Code - .ShouldBe(StatusCodes.BadContinuationPointInvalid); + .ShouldBe(StatusCodes.BadContinuationPointInvalid.Code); await host.DisposeAsync(); } @@ -120,7 +120,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable var (host, nm, node, _) = await SetupAsync(Distinct(10)); var raw = nm.HistoryContinuationStore.Save(null, new HistoryContinuationState("t", Epoch, Epoch.AddDays(1), 0, 4)); ReadEvents(nm, node, max: 4, inboundCp: raw).Error.StatusCode.Code - .ShouldBe(StatusCodes.BadContinuationPointInvalid); + .ShouldBe(StatusCodes.BadContinuationPointInvalid.Code); await host.DisposeAsync(); } @@ -141,7 +141,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable var p1 = ReadEvents(nm, node, max: 4, inboundCp: null); p1.Cp.ShouldNotBeNull(); ReadEvents(nm, other, max: 4, inboundCp: p1.Cp).Error.StatusCode.Code - .ShouldBe(StatusCodes.BadContinuationPointInvalid); + .ShouldBe(StatusCodes.BadContinuationPointInvalid.Code); await host.DisposeAsync(); } @@ -157,21 +157,21 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable byte[] RawRead(NodeId n, byte[]? cp, out ServiceResult err) { - var ctx = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null); + var ctx = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null); var res = new List { null! }; var errs = new List { null! }; nm.HistoryRead(ctx, new ReadRawModifiedDetails { ReturnBounds = false, StartTime = Epoch, EndTime = Epoch.AddHours(1), NumValuesPerNode = 2, IsReadModified = false }, TimestampsToReturn.Both, false, - new List { new() { NodeId = n, ContinuationPoint = cp } }, res, errs); + new List { new() { NodeId = n, ContinuationPoint = ByteString.From(cp) } }, res, errs); err = errs[0]; - return res[0]?.ContinuationPoint!; + return res[0]?.ContinuationPoint.ToNullableArray()!; } var cp = RawRead(a, null, out var err0); - err0.StatusCode.Code.ShouldBe(StatusCodes.Good, err0.ToString()); + err0.StatusCode.Code.ShouldBe(StatusCodes.Good.Code, err0.ToString()); cp.ShouldNotBeNull(); RawRead(b, cp, out var err); - err.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid); + err.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid.Code); await host.DisposeAsync(); } @@ -199,10 +199,10 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable good1[1].Cp.ShouldNotBeNull(); var mixed = ReadBatch(nm, 4, null, (node, good1[0].Cp), (other, Guid.NewGuid().ToByteArray())); - mixed[0].Error.StatusCode.Code.ShouldBe(StatusCodes.Good); + mixed[0].Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); // A resumed page is FULL (#675): it over-fetches by the boundary skip it then trims. mixed[0].Ids.ShouldBe(src.All.Skip(4).Take(4).Select(e => e.EventId)); - mixed[1].Error.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid); + mixed[1].Error.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid.Code); await host.DisposeAsync(); } @@ -225,7 +225,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable // The pre-#675 shape: the cap kept the EARLIEST N, ascending, Good, and no continuation point. var (host, nm, node, _) = await SetupAsync(Distinct(10)); var p1 = ReadEvents(nm, node, max: 3, inboundCp: null, end: Early, start: Late); - p1.Error.StatusCode.Code.ShouldBe(StatusCodes.Good); + p1.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); p1.Ids.ShouldBe(["e0009", "e0008", "e0007"]); p1.Cp.ShouldNotBeNull("a capped reverse read has more behind it and must page"); await host.DisposeAsync(); @@ -283,7 +283,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable var p1 = ReadEvents(nm, node, max: 3, inboundCp: null, end: Early, start: Late); p1.Cp.ShouldNotBeNull(); ReadEvents(nm, node, max: 3, inboundCp: p1.Cp, end: Early, start: Late).Error.StatusCode.Code - .ShouldBe(StatusCodes.BadTooManyOperations); + .ShouldBe(StatusCodes.BadTooManyOperations.Code); await host.DisposeAsync(); } @@ -295,7 +295,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable var p1 = ReadEvents(nm, node, max: 4, inboundCp: null, end: Early, start: Late); p1.Ids.ShouldBe(["e0009", "e0008", "e0007", "e0006"]); var p2 = ReadEvents(nm, node, max: 4, inboundCp: p1.Cp); // default details: Early -> Late (forward) - p2.Error.StatusCode.Code.ShouldBe(StatusCodes.Good); + p2.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); p2.Ids.ShouldBe(["e0005", "e0004", "e0003", "e0002"]); await host.DisposeAsync(); } @@ -340,7 +340,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable var fwd = new ForwardOnlyEventSource(src); nm.HistorianDataSource = fwd; var r = ReadEvents(nm, node, max: 3, inboundCp: null, end: Early, start: Late); - r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good); + r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); r.Ids.ShouldBe(["e0009", "e0008", "e0007"]); r.Cp.ShouldNotBeNull(); // Forward reads on the same backend are unaffected. @@ -469,7 +469,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable break; } - r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good, $"seed {seed}, ms max {msMax}"); + r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code, $"seed {seed}, ms max {msMax}"); all.AddRange(r.Ids); cp = r.Cp; (++pages).ShouldBeLessThan(2000); @@ -492,7 +492,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable var (host, nm, node, _) = await SetupAsync(Distinct(10), logger: logger); nm.HistorianDataSource = new ThrowingReverseSource(); ReadEvents(nm, node, max: 3, inboundCp: null, end: Early, start: Late).Error.StatusCode.Code - .ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + .ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); logger.Entries.ShouldContain(e => e.Level == LogLevel.Error && e.Exception is NotSupportedException); logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning); await host.DisposeAsync(); @@ -580,7 +580,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable { public Task ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) { - var rows = Enumerable.Range(0, 10).Select(i => new DataValueSnapshot((double)i, StatusCodes.Good, + var rows = Enumerable.Range(0, 10).Select(i => new DataValueSnapshot((double)i, StatusCodes.Good.Code, Epoch.AddSeconds(i), Epoch.AddSeconds(i))).Where(v => v.SourceTimestampUtc >= s && v.SourceTimestampUtc <= e) .Take((int)m).ToList(); return Task.FromResult(new HistorianRead(rows, null)); @@ -646,7 +646,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable do { var r = ReadEvents(nm, node, max, cp, end, start); - r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good); + r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); all.AddRange(r.Ids); pageSizes?.Add(r.Ids.Count); cp = r.Cp; @@ -668,12 +668,12 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable OtOpcUaNodeManager nm, uint max, DateTime? end, DateTime? start, params (NodeId Node, byte[]? Cp)[] reads) { var context = new OperationContext( - new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null); + new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null); var filter = new EventFilter(); - filter.SelectClauses.Add(new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("EventId")) + filter.SelectClauses = [new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("EventId")) { AttributeId = Attributes.Value, - }); + }]; var details = new ReadEventDetails { StartTime = start ?? Epoch.AddDays(-1), @@ -681,7 +681,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable NumValuesPerNode = max, Filter = filter, }; - var nodesToRead = reads.Select(r => new HistoryReadValueId { NodeId = r.Node, ContinuationPoint = r.Cp }).ToList(); + var nodesToRead = reads.Select(r => new HistoryReadValueId { NodeId = r.Node, ContinuationPoint = ByteString.From(r.Cp) }).ToList(); var results = reads.Select(_ => (SdkHistoryReadResult)null!).ToList(); var errors = reads.Select(_ => (ServiceResult)null!).ToList(); nm.HistoryRead(context, details, TimestampsToReturn.Both, false, nodesToRead, results, errors); @@ -690,14 +690,14 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable for (var i = 0; i < reads.Length; i++) { var ids = new List(); - if (results[i]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho)) + if (results[i]?.HistoryData is { } ho && !ho.IsNull) { - var he = (HistoryEvent)ExtensionObject.ToEncodeable(ho); + var he = (HistoryEvent)ExtensionObject.ToEncodeable(ho)!; foreach (var e in he.Events) - ids.Add(System.Text.Encoding.UTF8.GetString((byte[])e.EventFields[0].Value)); + ids.Add(System.Text.Encoding.UTF8.GetString((byte[])SdkValueBoundary.ToClr(e.EventFields[0])!)); } - all.Add((ids, errors[i], results[i]?.ContinuationPoint)); + all.Add((ids, errors[i], results[i]?.ContinuationPoint.ToNullableArray())); } return all; @@ -706,8 +706,8 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable private static void InvokeRelease(OtOpcUaNodeManager nm, NodeId node, byte[] cp) { var context = new OperationContext( - new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null); - var nodesToRead = new List { new() { NodeId = node, ContinuationPoint = cp } }; + new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null); + var nodesToRead = new List { new() { NodeId = node, ContinuationPoint = ByteString.From(cp) } }; var results = new List { null! }; var errors = new List { null! }; nm.HistoryRead(context, new ReadEventDetails { StartTime = Epoch, EndTime = Epoch.AddDays(1), NumValuesPerNode = 4 }, diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsProjectionTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsProjectionTests.cs index 07b6eeba..107097a2 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsProjectionTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsProjectionTests.cs @@ -41,19 +41,19 @@ public sealed class NodeManagerHistoryReadEventsProjectionTests : IDisposable var history = Read(server, nm, notifier, Select("EventId", "EventType", "ConditionName", "SourceNode"), where: null); - var rows = history.Events.ToDictionary(e => System.Text.Encoding.UTF8.GetString((byte[])e.EventFields[0].Value)); - rows["e-limit"].EventFields[1].Value.ShouldBe(ObjectTypeIds.LimitAlarmType); - rows["e-limit"].EventFields[2].Value.ShouldBe("HighTemp"); - rows["e-limit"].EventFields[3].Value.ShouldBe(condition, "the alarm id resolves to the materialised condition — the live events' SourceNode"); + var rows = history.Events.ToArray()!.ToDictionary(e => System.Text.Encoding.UTF8.GetString((byte[])SdkValueBoundary.ToClr(e.EventFields[0])!)); + SdkValueBoundary.ToClr(rows["e-limit"].EventFields[1])!.ShouldBe(ObjectTypeIds.LimitAlarmType); + SdkValueBoundary.ToClr(rows["e-limit"].EventFields[2])!.ShouldBe("HighTemp"); + SdkValueBoundary.ToClr(rows["e-limit"].EventFields[3])!.ShouldBe(condition, "the alarm id resolves to the materialised condition — the live events' SourceNode"); - rows["e-offnormal"].EventFields[1].Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType); - rows["e-offnormal"].EventFields[3].Value.ShouldBeNull("no condition is materialised for that alarm id"); + SdkValueBoundary.ToClr(rows["e-offnormal"].EventFields[1])!.ShouldBe(ObjectTypeIds.OffNormalAlarmType); + SdkValueBoundary.ToClr(rows["e-offnormal"].EventFields[3])!.ShouldBeNull("no condition is materialised for that alarm id"); // No stored type: still an alarm condition (every historical row is alarm history); an unrecognised or non-event // type name is not passed through as a non-event type. - rows["e-untyped"].EventFields[1].Value.ShouldBe(ObjectTypeIds.AlarmConditionType); - rows["e-untyped"].EventFields[2].Value.ShouldBeNull(); - rows["e-bogus"].EventFields[1].Value.ShouldBe(ObjectTypeIds.AlarmConditionType); + SdkValueBoundary.ToClr(rows["e-untyped"].EventFields[1])!.ShouldBe(ObjectTypeIds.AlarmConditionType); + SdkValueBoundary.ToClr(rows["e-untyped"].EventFields[2])!.ShouldBeNull(); + SdkValueBoundary.ToClr(rows["e-bogus"].EventFields[1])!.ShouldBe(ObjectTypeIds.AlarmConditionType); await host.DisposeAsync(); } @@ -79,9 +79,7 @@ public sealed class NodeManagerHistoryReadEventsProjectionTests : IDisposable { var (host, server, nm, notifier) = await BootAsync(); var where = new ContentFilter(); - where.Push(FilterOperator.GreaterThan, - new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("Severity")) { AttributeId = Attributes.Value }, - new LiteralOperand(new Variant((ushort)600))); + where.Push(FilterOperator.GreaterThan, Variant.FromStructure(new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("Severity")) { AttributeId = Attributes.Value }), Variant.FromStructure(new LiteralOperand(new Variant((ushort)600)))); Ids(Read(server, nm, notifier, Select("EventId"), where)).ShouldBe(["e-limit", "e-bogus"]); await host.DisposeAsync(); @@ -100,13 +98,13 @@ public sealed class NodeManagerHistoryReadEventsProjectionTests : IDisposable switch (fault) { case "unknown operator": - where.Push((FilterOperator)99, new LiteralOperand(new Variant(1)), new LiteralOperand(new Variant(1))); + where.Push((FilterOperator)99, Variant.FromStructure(new LiteralOperand(new Variant(1))), Variant.FromStructure(new LiteralOperand(new Variant(1)))); break; case "self-referencing element": - where.Push(FilterOperator.Not, new ElementOperand(0)); + where.Push(FilterOperator.Not, Variant.FromStructure(new ElementOperand(0))); break; default: - where.Push(FilterOperator.Equals, new LiteralOperand(new Variant(1))); + where.Push(FilterOperator.Equals, Variant.FromStructure(new LiteralOperand(new Variant(1)))); break; } @@ -115,39 +113,39 @@ public sealed class NodeManagerHistoryReadEventsProjectionTests : IDisposable // Positive control: the same read with a valid where-clause is served. var valid = new ContentFilter(); - valid.Push(FilterOperator.Equals, new LiteralOperand(new Variant(1)), new LiteralOperand(new Variant(1))); + valid.Push(FilterOperator.Equals, Variant.FromStructure(new LiteralOperand(new Variant(1))), Variant.FromStructure(new LiteralOperand(new Variant(1)))); StatusCode.IsGood(ReadStatus(server, nm, notifier, Select("EventId"), valid).Status).ShouldBeTrue(); await host.DisposeAsync(); } private static string[] Ids(HistoryEvent h) => - h.Events.Select(e => System.Text.Encoding.UTF8.GetString((byte[])e.EventFields[0].Value)).ToArray(); + h.Events.ToArray()!.Select(e => System.Text.Encoding.UTF8.GetString((byte[])SdkValueBoundary.ToClr(e.EventFields[0])!)).ToArray(); private static ContentFilter OfType(NodeId type) { var f = new ContentFilter(); - f.Push(FilterOperator.OfType, new LiteralOperand(new Variant(type))); + f.Push(FilterOperator.OfType, Variant.FromStructure(new LiteralOperand(new Variant(type)))); return f; } - private static SimpleAttributeOperandCollection Select(params string[] leaves) + private static List Select(params string[] leaves) { - var c = new SimpleAttributeOperandCollection(); + var c = new List(); foreach (var leaf in leaves) c.Add(new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName(leaf)) { AttributeId = Attributes.Value }); return c; } private static HistoryEvent Read(OtOpcUaSdkServer server, OtOpcUaNodeManager nm, NodeId notifier, - SimpleAttributeOperandCollection select, ContentFilter? where) + List select, ContentFilter? where) { var (status, result) = ReadStatus(server, nm, notifier, select, where); StatusCode.IsGood(status).ShouldBeTrue($"HistoryRead failed: {status}"); - return (HistoryEvent)ExtensionObject.ToEncodeable(result.HistoryData); + return (HistoryEvent)ExtensionObject.ToEncodeable(result.HistoryData)!; } private static (StatusCode Status, SdkHistoryReadResult Result) ReadStatus(OtOpcUaSdkServer server, OtOpcUaNodeManager nm, - NodeId notifier, SimpleAttributeOperandCollection select, ContentFilter? where) + NodeId notifier, List select, ContentFilter? where) { var details = new ReadEventDetails { @@ -156,7 +154,7 @@ public sealed class NodeManagerHistoryReadEventsProjectionTests : IDisposable NumValuesPerNode = 100, Filter = new EventFilter { SelectClauses = select, WhereClause = where ?? new ContentFilter() }, }; - var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null); + var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null); var results = new List { null! }; var errors = new List { null! }; nm.HistoryRead(context, details, TimestampsToReturn.Both, releaseContinuationPoints: false, diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs index b2fa28c4..bf932259 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs @@ -71,26 +71,26 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable fake.LastEnd.ShouldBe(end); fake.LastMaxEvents.ShouldBe(50); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); - results[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); + results[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); - var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData); + var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData)!; history.Events.Count.ShouldBe(1); var fields = history.Events[0].EventFields; fields.Count.ShouldBe(6); // EventId ⇒ ByteString (UTF-8 of "evt-42"). - fields[0].Value.ShouldBeOfType().ShouldBe(System.Text.Encoding.UTF8.GetBytes("evt-42")); + SdkValueBoundary.ToClr(fields[0])!.ShouldBeOfType().ShouldBe(System.Text.Encoding.UTF8.GetBytes("evt-42")); // SourceName ⇒ string. - fields[1].Value.ShouldBe("Pump_001"); + SdkValueBoundary.ToClr(fields[1])!.ShouldBe("Pump_001"); // Time ⇒ DateTime (event occurrence time). - fields[2].Value.ShouldBe(evtTime); + SdkValueBoundary.ToClr(fields[2])!.ShouldBe(evtTime); // ReceiveTime ⇒ DateTime (server receipt time = ReceivedTimeUtc). - fields[3].Value.ShouldBe(rcvTime); + SdkValueBoundary.ToClr(fields[3])!.ShouldBe(rcvTime); // Message ⇒ LocalizedText. - fields[4].Value.ShouldBeOfType().Text.ShouldBe("Pump tripped"); + SdkValueBoundary.ToClr(fields[4])!.ShouldBeOfType().Text.ShouldBe("Pump tripped"); // Severity ⇒ UInt16. - fields[5].Value.ShouldBe((ushort)700); + SdkValueBoundary.ToClr(fields[5])!.ShouldBe((ushort)700); await host.DisposeAsync(); } @@ -126,7 +126,7 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable var (_, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); // The backend saw the unbounded cap, NOT the 0/default-cap sentinel. fake.LastMaxEvents.ShouldBe(int.MaxValue); @@ -163,14 +163,14 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable var (results, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); - var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); + var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData)!; var fields = history.Events[0].EventFields; fields.Count.ShouldBe(2); // LocalTime ⇒ Variant.Null. - fields[0].Value.ShouldBeNull(); + SdkValueBoundary.ToClr(fields[0])!.ShouldBeNull(); // EventId still projects to a ByteString. - fields[1].Value.ShouldBeOfType().ShouldBe(System.Text.Encoding.UTF8.GetBytes("evt-1")); + SdkValueBoundary.ToClr(fields[1])!.ShouldBeOfType().ShouldBe(System.Text.Encoding.UTF8.GetBytes("evt-1")); await host.DisposeAsync(); } @@ -203,10 +203,10 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable var (results, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); - results[0].StatusCode.Code.ShouldBe(StatusCodes.GoodNoData); - var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData); - history.Events.ShouldBeEmpty(); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); + results[0].StatusCode.Code.ShouldBe(StatusCodes.GoodNoData.Code); + var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData)!; + history.Events.Count.ShouldBe(0); await host.DisposeAsync(); } @@ -236,14 +236,14 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable }; var (_, unsupported) = InvokeHistoryRead(server, nm, details, notifierNodeId); - unsupported[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + unsupported[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); var fake = new RecordingHistorianDataSource(); nm.HistorianDataSource = fake; var (_, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); fake.EventsCalled.ShouldBeTrue(); fake.LastSourceName.ShouldBe(equipmentId); @@ -282,8 +282,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable }; var (results, errors) = InvokeHistoryRead(server, nm, details, folder.NodeId); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); - results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported, "set by the override's guard"); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); + results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code, "set by the override's guard"); fake.EventsCalled.ShouldBeFalse(); await host.DisposeAsync(); @@ -327,7 +327,7 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable var (_, errors) = InvokeHistoryRead(server, nm, details, nodeId); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); fake.EventsCalled.ShouldBeFalse(); await host.DisposeAsync(); @@ -365,10 +365,10 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable // Authoritative per-node signal: errors[0] must be Bad. ServiceResult.IsBad(errors[0]).ShouldBeTrue(); - errors[0].StatusCode.Code.ShouldBe(communication ? StatusCodes.BadNoCommunication : StatusCodes.BadHistoryOperationUnsupported); + errors[0].StatusCode.Code.ShouldBe(communication ? StatusCodes.BadNoCommunication.Code : StatusCodes.BadHistoryOperationUnsupported.Code); // The production catch now sets results[0] explicitly — assert it directly (not relying on base seeding). results[0].ShouldNotBeNull(); - results[0].StatusCode.Code.ShouldBe(communication ? StatusCodes.BadNoCommunication : StatusCodes.BadHistoryOperationUnsupported); + results[0].StatusCode.Code.ShouldBe(communication ? StatusCodes.BadNoCommunication.Code : StatusCodes.BadHistoryOperationUnsupported.Code); // The source WAS entered (proving the override reached the bridge and the throw was swallowed). fake.EventsEntered.ShouldBeTrue(); // ThrowOnRead fires before EventsCalled/LastSourceName are set — that's the throw path. @@ -382,15 +382,17 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable private static EventFilter SelectFilter(params string[] leafFieldNames) { var filter = new EventFilter(); + var clauses = new List(); foreach (var name in leafFieldNames) { - filter.SelectClauses.Add( + clauses.Add( new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName(name)) { AttributeId = Attributes.Value, }); } + filter.SelectClauses = clauses; return filter; } @@ -400,7 +402,7 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable OtOpcUaSdkServer server, OtOpcUaNodeManager nm, HistoryReadDetails details, NodeId nodeId) { var context = new OperationContext( - new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null); + new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null); var nodesToRead = new List { new() { NodeId = nodeId } }; var results = new List { null! }; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadModifiedTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadModifiedTests.cs index 1e62ebc8..a8718445 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadModifiedTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadModifiedTests.cs @@ -32,9 +32,9 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable try { var r = Read(nm, node, Epoch, Epoch.AddHours(1), 10, null); - r.Data!.DataValues.Single().Value.ShouldBeOfType().ShouldBe(2f); - r.Data.ModificationInfos.Single().UserName.ShouldBe("alice"); - r.Data.ModificationInfos.Single().UpdateType.ShouldBe(HistoryUpdateType.Replace); + SdkValueBoundary.ToClr(r.Data!.DataValues.ToArray()!.Single().WrappedValue)!.ShouldBeOfType().ShouldBe(2f); + r.Data.ModificationInfos.ToArray()!.Single().UserName.ShouldBe("alice"); + r.Data.ModificationInfos.ToArray()!.Single().UpdateType.ShouldBe(HistoryUpdateType.Replace); } finally { await host.DisposeAsync(); } } @@ -47,8 +47,8 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable { src.Throw = new System.IO.IOException("connection lost"); var r = Read(nm, node, Epoch, Epoch.AddHours(1), 10, null); - r.Error.StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication); - r.Result.StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication); + r.Error.StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication.Code); + r.Result.StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication.Code); } finally { await host.DisposeAsync(); } } @@ -67,8 +67,8 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable var r = Read(nm, node, Epoch, Epoch.AddHours(1), max: 100, cp: null); - r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good); - r.Result.StatusCode.Code.ShouldBe(StatusCodes.Good); + r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); + r.Result.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); r.Cp.ShouldBeNull(); src.LastTag.ShouldBe(Tag); src.LastStart.ShouldBe(Epoch); @@ -79,16 +79,16 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable var data = r.Data!; data.DataValues.Count.ShouldBe(3); data.ModificationInfos.Count.ShouldBe(3); - data.ModificationInfos.Select(m => m.UpdateType) + data.ModificationInfos.ToArray()!.Select(m => m.UpdateType) .ShouldBe([HistoryUpdateType.Insert, HistoryUpdateType.Replace, HistoryUpdateType.Delete]); - data.ModificationInfos.Select(m => m.UserName).ShouldBe(["alice", "bob", "carol"]); - data.ModificationInfos.Select(m => m.ModificationTime) + data.ModificationInfos.ToArray()!.Select(m => m.UserName).ShouldBe(["alice", "bob", "carol"]); + data.ModificationInfos.ToArray()!.Select(m => m.ModificationTime) .ShouldBe([modifiedAt, modifiedAt.AddMinutes(1), modifiedAt.AddMinutes(2)]); - data.DataValues[0].Value.ShouldBeNull(); - data.DataValues[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoData); - data.DataValues[1].Value.ShouldBe(1.5); + SdkValueBoundary.ToClr(data.DataValues[0].WrappedValue)!.ShouldBeNull(); + data.DataValues[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoData.Code); + SdkValueBoundary.ToClr(data.DataValues[1].WrappedValue)!.ShouldBe(1.5); data.DataValues[1].SourceTimestamp.ShouldBe(Epoch.AddSeconds(10)); - data.DataValues[2].Value.ShouldBe(7.0); + SdkValueBoundary.ToClr(data.DataValues[2].WrappedValue)!.ShouldBe(7.0); await host.DisposeAsync(); } @@ -97,9 +97,9 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable { var (host, nm, node, _) = await SetupAsync([]); var r = Read(nm, node, Epoch, Epoch.AddHours(1), max: 10, cp: null); - r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good); - r.Result.StatusCode.Code.ShouldBe(StatusCodes.GoodNoData); - r.Data!.ModificationInfos.ShouldBeEmpty(); + r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); + r.Result.StatusCode.Code.ShouldBe(StatusCodes.GoodNoData.Code); + r.Data!.ModificationInfos.Count.ShouldBe(0); await host.DisposeAsync(); } @@ -172,7 +172,7 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable { // Inserts have no prior value; their snapshot has no source timestamp. The cursor must not fall back to MinValue. var trail = Enumerable.Range(1, 6).Select(i => new HistoryModifiedValue( - new DataValueSnapshot(null, StatusCodes.BadNoData, null, Epoch), Epoch, HistoryModificationType.Insert, "u", i, + new DataValueSnapshot(null, StatusCodes.BadNoData.Code, null, Epoch), Epoch, HistoryModificationType.Insert, "u", i, Epoch.AddSeconds(i))).ToList(); var (host, nm, node, src) = await SetupAsync(trail); @@ -214,20 +214,20 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable // A RAW cursor for the same tag is not a modified cursor. var raw = nm.HistoryContinuationStore.Save(null, new HistoryContinuationState(Tag, Epoch, Epoch.AddDays(1), 0, 4)); Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: raw).Error.StatusCode.Code - .ShouldBe(StatusCodes.BadContinuationPointInvalid); + .ShouldBe(StatusCodes.BadContinuationPointInvalid.Code); // A MODIFIED cursor presented to a raw read is invalid too. var p1 = Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: null); p1.Cp.ShouldNotBeNull(); Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: p1.Cp, modified: false).Error.StatusCode.Code - .ShouldBe(StatusCodes.BadContinuationPointInvalid); + .ShouldBe(StatusCodes.BadContinuationPointInvalid.Code); // A modified cursor issued for one node is invalid on another node's tag. var p2 = Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: null); nm.EnsureVariable("eq-1/other", null, "Other", "Double", false, historianTagname: "WW.Other", realm: AddressSpaceRealm.Uns); var other = nm.TryGetVariable("eq-1/other")!.NodeId; Read(nm, other, Epoch, Epoch.AddHours(1), max: 4, cp: p2.Cp).Error.StatusCode.Code - .ShouldBe(StatusCodes.BadContinuationPointInvalid); + .ShouldBe(StatusCodes.BadContinuationPointInvalid.Code); await host.DisposeAsync(); } @@ -245,9 +245,9 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable var modified = Read(nm, node, Epoch, Epoch.AddHours(1), max: 10, cp: null); var raw = Read(nm, node, Epoch, Epoch.AddHours(1), max: 10, cp: null, modified: false); - modified.Error.StatusCode.Code.ShouldBe(StatusCodes.Good); + modified.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); modified.Data!.DataValues.Count.ShouldBe(1); - raw.Error.StatusCode.Code.ShouldNotBe(StatusCodes.BadInvalidState); + raw.Error.StatusCode.Code.ShouldNotBe(StatusCodes.BadInvalidState.Code); src.Calls.ShouldBe(1); consulted.ShouldBe(0); await host.DisposeAsync(); @@ -259,7 +259,7 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable var (host, nm, node, src) = await SetupAsync([Mod(1, 1, 1, HistoryModificationType.Update, "u", Epoch)]); src.Throw = new InvalidOperationException("boom"); var r = Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: null); - r.Error.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + r.Error.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); await host.DisposeAsync(); } @@ -270,8 +270,8 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable { var t = Epoch.AddSeconds(seconds); var value = prior is null - ? new DataValueSnapshot(null, StatusCodes.BadNoData, t, modifiedAt) - : new DataValueSnapshot(prior.Value, StatusCodes.Good, t, t); + ? new DataValueSnapshot(null, StatusCodes.BadNoData.Code, t, modifiedAt) + : new DataValueSnapshot(prior.Value, StatusCodes.Good.Code, t, t); return new HistoryModifiedValue(value, modifiedAt, type, user, id, t); } @@ -283,7 +283,7 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable do { var r = Read(nm, node, start, end, max, cp); - r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good, r.Error.ToString()); + r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code, r.Error.ToString()); ids.AddRange(r.Ids); cp = r.Cp; (++pages).ShouldBeLessThan(60, "paging must terminate"); @@ -295,25 +295,25 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable private static (ServiceResult Error, SdkHistoryReadResult Result, HistoryModifiedData? Data, byte[]? Cp, List Ids) Read( OtOpcUaNodeManager nm, NodeId node, DateTime start, DateTime end, uint max, byte[]? cp, bool modified = true) { - var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null); + var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null); var results = new List { null! }; var errors = new List { null! }; nm.HistoryRead(context, new ReadRawModifiedDetails { ReturnBounds = false, StartTime = start, EndTime = end, NumValuesPerNode = max, IsReadModified = modified }, TimestampsToReturn.Both, false, - new List { new() { NodeId = node, ContinuationPoint = cp } }, results, errors); + new List { new() { NodeId = node, ContinuationPoint = ByteString.From(cp) } }, results, errors); HistoryModifiedData? data = null; var ids = new List(); - if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho) - && ExtensionObject.ToEncodeable(ho) is HistoryModifiedData hm) + if (results[0]?.HistoryData is { } ho && !ho.IsNull + && ExtensionObject.ToEncodeable(ho)! is HistoryModifiedData hm) { data = hm; // The fake stores each modification's id in its (unique) server timestamp offset — see SqlLikeTrail. - ids.AddRange(hm.DataValues.Select(v => SqlLikeTrail.IdOf(v))); + ids.AddRange(hm.DataValues.ToArray()!.Select(v => SqlLikeTrail.IdOf(v))); } - return (errors[0], results[0], data, results[0]?.ContinuationPoint, ids); + return (errors[0], results[0], data, results[0]?.ContinuationPoint.ToNullableArray(), ids); } private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm, NodeId Node, SqlLikeTrail Src)> SetupAsync( @@ -355,7 +355,7 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable public int Calls { get; private set; } public Exception? Throw { get; set; } - public static long IdOf(DataValue v) => v.ServerTimestamp.Ticks - Epoch.Ticks; + public static long IdOf(DataValue v) => v.ServerTimestamp.ToDateTime().Ticks - Epoch.Ticks; public IEnumerable Ordered(bool reverse) => reverse ? trail.OrderByDescending(m => m.SourceTimeUtc).ThenByDescending(m => m.ModifiedId) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPageFillTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPageFillTests.cs index 87ae0cde..e93fbce9 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPageFillTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPageFillTests.cs @@ -29,7 +29,7 @@ public sealed class NodeManagerHistoryReadPageFillTests(BoundsServerFixture fx) private static DataValueSnapshot[] EightValues() => Enumerable.Range(1, 8).Select(i => Sample(i, T0.AddSeconds(i))).ToArray(); - private static DataValueSnapshot Sample(double v, DateTime t) => new(v, StatusCodes.Good, t, t); + private static DataValueSnapshot Sample(double v, DateTime t) => new(v, StatusCodes.Good.Code, t, t); private static (DateTime Start, DateTime End) Window(Direction direction) => direction switch { @@ -193,7 +193,7 @@ public sealed class NodeManagerHistoryReadPageFillTests(BoundsServerFixture fx) { source.ResetCounts(); var (values, error, next) = await ReadAsync(source, start, end, 1, bounds: false, cp); - error.StatusCode.Code.ShouldBe(StatusCodes.Good); + error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); values.Count.ShouldBe(1); all.AddRange(values); readsPerPage.Add(source.Reads); @@ -202,7 +202,7 @@ public sealed class NodeManagerHistoryReadPageFillTests(BoundsServerFixture fx) } while (cp is not null); - all.Select(x => (double)x.Value).ShouldBe([0d, 1d, 2d, 3d, 4d, 5d, 6d]); + all.Select(x => (double)SdkValueBoundary.ToClr(x.WrappedValue)!).ShouldBe([0d, 1d, 2d, 3d, 4d, 5d, 6d]); // Page 1 [a], page 2 [t1]: one read each. Page 3 [t2]: the read stalls on the cluster, then the over-fetch (2). // Pages 4 [t3], 5 [t4], 6 [t5]: the flagged cursor over-fetches directly (1 each). Page 7 [b]: one read. readsPerPage.ShouldBe([1, 1, 2, 1, 1, 1, 1]); @@ -227,12 +227,12 @@ public sealed class NodeManagerHistoryReadPageFillTests(BoundsServerFixture fx) var (start, end) = direction == Direction.Forward ? (T0.AddSeconds(5), T0.AddSeconds(7)) : (T0.AddSeconds(7), T0.AddSeconds(5)); var (first, _, cp) = await ReadAsync(source, start, end, 1, bounds: true, cp: null); - first.Single().StatusCode.Code.ShouldBe(StatusCodes.BadBoundNotFound); + first.Single().StatusCode.Code.ShouldBe(StatusCodes.BadBoundNotFound.Code); cp.ShouldNotBeNull(); source.ResetCounts(); var (second, _, cp2) = await ReadAsync(source, start, end, 1, bounds: true, cp: cp); - second.Single().Value.ShouldBe(direction == Direction.Forward ? 9d : 3d); + SdkValueBoundary.ToClr(second.Single().WrappedValue)!.ShouldBe(direction == Direction.Forward ? 9d : 3d); cp2.ShouldBeNull(); source.Reads.ShouldBe(0, "the end bound was found by the first page and carried"); } @@ -253,7 +253,7 @@ public sealed class NodeManagerHistoryReadPageFillTests(BoundsServerFixture fx) fx.Nm.HistorianDataSource = source; // Reverse (0.1s ← 30s]: no sample at or after 30s ⇒ the start bound is a placeholder; the data is below. var (values, _, cp) = Read(T0.AddSeconds(30), T0.AddMilliseconds(100), 1, bounds: true, cp: null); - values.Single().StatusCode.Code.ShouldBe(StatusCodes.BadBoundNotFound); + values.Single().StatusCode.Code.ShouldBe(StatusCodes.BadBoundNotFound.Code); cp.ShouldNotBeNull(); store.Saved.Last().WalkHint.WidthTicks.ShouldBeGreaterThan(0); } @@ -272,7 +272,7 @@ public sealed class NodeManagerHistoryReadPageFillTests(BoundsServerFixture fx) [Fact] public async Task A_stored_row_with_the_bound_not_found_status_is_data_not_a_placeholder() { - var stored = new DataValueSnapshot(null, StatusCodes.BadBoundNotFound, T0.AddSeconds(6), T0.AddSeconds(6)); + var stored = new DataValueSnapshot(null, StatusCodes.BadBoundNotFound.Code, T0.AddSeconds(6), T0.AddSeconds(6)); HistoryBounds.IsPlaceholder(stored).ShouldBeFalse(); HistoryBounds.IsPlaceholder(HistoryBounds.NotFound(T0)).ShouldBeTrue(); @@ -285,12 +285,12 @@ public sealed class NodeManagerHistoryReadPageFillTests(BoundsServerFixture fx) // --- helpers -------------------------------------------------------------------------------- private static string Render(IEnumerable values) => - string.Join(" | ", values.Select(v => $"{v.SourceTimestamp:HH:mm:ss.fffffff}={v.Value ?? "null"}/{v.StatusCode.Code:X8}")); + string.Join(" | ", values.Select(v => $"{v.SourceTimestamp:HH:mm:ss.fffffff}={SdkValueBoundary.ToClr(v.WrappedValue)! ?? "null"}/{v.StatusCode.Code:X8}")); private async Task> SinglePageAsync(CountingSource source, DateTime start, DateTime end, bool bounds) { var (values, error, cp) = await ReadAsync(source, start, end, 10_000, bounds, cp: null); - error.StatusCode.Code.ShouldBe(StatusCodes.Good); + error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); cp.ShouldBeNull(); return values.ToList(); } @@ -303,7 +303,7 @@ public sealed class NodeManagerHistoryReadPageFillTests(BoundsServerFixture fx) do { var (values, error, next) = await ReadAsync(source, start, end, max, bounds, cp); - error.StatusCode.Code.ShouldBe(StatusCodes.Good); + error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); pages.Add(values); cp = next; pages.Count.ShouldBeLessThan(80, "paging must terminate"); @@ -328,20 +328,20 @@ public sealed class NodeManagerHistoryReadPageFillTests(BoundsServerFixture fx) private (IReadOnlyList Values, ServiceResult Error, byte[]? Cp) Read( DateTime start, DateTime end, uint max, bool bounds, byte[]? cp) { - var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null); + var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null); var details = new ReadRawModifiedDetails { StartTime = start, EndTime = end, NumValuesPerNode = max, ReturnBounds = bounds, IsReadModified = false, }; - var nodesToRead = new List { new() { NodeId = fx.NodeId, ContinuationPoint = cp } }; + var nodesToRead = new List { new() { NodeId = fx.NodeId, ContinuationPoint = ByteString.From(cp) } }; var results = new List { null! }; var errors = new List { null! }; fx.Nm.HistoryRead(context, details, TimestampsToReturn.Both, releaseContinuationPoints: false, nodesToRead, results, errors); var values = new List(); - if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho)) - values.AddRange(((HistoryData)ExtensionObject.ToEncodeable(ho)).DataValues); - return (values, errors[0], results[0]?.ContinuationPoint); + if (results[0]?.HistoryData is { } ho && !ho.IsNull) + values.AddRange(((HistoryData)ExtensionObject.ToEncodeable(ho)!).DataValues); + return (values, errors[0], results[0]?.ContinuationPoint.ToNullableArray()); } private static CountingSource Source(Shape shape, IReadOnlyList series) => shape switch diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs index 4a471f56..1dbec737 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs @@ -54,7 +54,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable { var (values, error, nextCp) = ReadRaw(nm, nodeId, start: Epoch, end: Epoch.AddHours(1), max: 100, inboundCp: cp); - error.StatusCode.Code.ShouldBe(StatusCodes.Good); + error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); collected.AddRange(values); cp = nextCp; if (cp is not null) sawCp = true; @@ -92,7 +92,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable do { var (values, error, next) = ReadRaw(nm, nodeId, Epoch, DateTime.MinValue, max: 100, inboundCp: cp); - error.StatusCode.Code.ShouldBe(StatusCodes.Good); + error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); collected.AddRange(values); cp = next; (++pages).ShouldBeLessThan(20); @@ -121,7 +121,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable do { var (values, error, next) = ReadRaw(nm, nodeId, DateTime.MinValue, Epoch.AddHours(1), max: 100, inboundCp: cp); - error.StatusCode.Code.ShouldBe(StatusCodes.Good); + error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); collected.AddRange(values); cp = next; (++pages).ShouldBeLessThan(20); @@ -155,7 +155,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 101, stepSeconds: 1)); var (r2, e2, cp2) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: null); r2.Count.ShouldBe(100); - e2.StatusCode.Code.ShouldBe(StatusCodes.Good); + e2.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); cp2.ShouldNotBeNull(); var (r3, _, cp3) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: cp2); r3.ShouldBe([100d]); @@ -184,7 +184,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable Epoch.AddSeconds(2), // 3 │ tied at Epoch+2s Epoch.AddSeconds(2), // 4 ┘ }; - var series = ts.Select((t, i) => new DataValueSnapshot((double)i, StatusCodes.Good, t, t)).ToArray(); + var series = ts.Select((t, i) => new DataValueSnapshot((double)i, StatusCodes.Good.Code, t, t)).ToArray(); nm.HistorianDataSource = new SeriesHistorianDataSource(series); nm.EnsureVariable("eq-1/tie", parentFolderNodeId: null, displayName: "Tie", dataType: "Double", @@ -242,7 +242,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable { var (values, error, nextCp) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 2, inboundCp: cp); - error.StatusCode.Code.ShouldBe(StatusCodes.Good); + error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); collected.AddRange(values); cp = nextCp; pageCount++; @@ -273,7 +273,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable // 5 samples ALL sharing one timestamp (Epoch+2s) — a tie cluster of 5 with a page cap of 2. var t = Epoch.AddSeconds(2); var series = Enumerable.Range(0, 5) - .Select(i => new DataValueSnapshot((double)i, StatusCodes.Good, t, t)).ToArray(); + .Select(i => new DataValueSnapshot((double)i, StatusCodes.Good.Code, t, t)).ToArray(); nm.HistorianDataSource = new SeriesHistorianDataSource(series); nm.EnsureVariable("eq-1/absurd", parentFolderNodeId: null, displayName: "Absurd", dataType: "Double", @@ -282,14 +282,14 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable // Page 1: a full page of the first 2 ties, with a continuation point. var (r1, e1, cp1) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 2, inboundCp: null); - e1.StatusCode.Code.ShouldBe(StatusCodes.Good); + e1.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); r1.ShouldBe(new[] { 0.0, 1.0 }); cp1.ShouldNotBeNull(); // Page 2: the resume read hits the cluster; over-fetch finds 5 > bound 3 ⇒ a clear error, NOT a // silent GoodNoData that would drop the un-emitted ties. var (r2, e2, cp2) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 2, inboundCp: cp1); - e2.StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations); + e2.StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations.Code); r2.ShouldBeEmpty(); cp2.ShouldBeNull(); @@ -312,7 +312,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable var (r, e, cp) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 0, inboundCp: null); - e.StatusCode.Code.ShouldBe(StatusCodes.Good); + e.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); r.Count.ShouldBe(250); cp.ShouldBeNull("an unlimited (max==0) request must not page"); @@ -338,7 +338,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable var bogus = Guid.NewGuid().ToByteArray(); var (_, e, cp) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: bogus); - e.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid); + e.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid.Code); cp.ShouldBeNull(); fake.ReadCount.ShouldBe(0); // never reached the backend. @@ -372,7 +372,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable // Resuming the released point now misses. var (_, e, _) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: cp1); - e.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid); + e.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid.Code); await host.DisposeAsync(); } @@ -436,7 +436,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore(); // Microsecond-spaced samples, so a one-tick step would land strictly between two of them. var series = Enumerable.Range(0, 6) - .Select(i => { var t = Epoch.AddTicks(10L * i); return new DataValueSnapshot((double)i, StatusCodes.Good, t, t); }) + .Select(i => { var t = Epoch.AddTicks(10L * i); return new DataValueSnapshot((double)i, StatusCodes.Good.Code, t, t); }) .ToArray(); nm.HistorianDataSource = new MicrosecondHistorianDataSource(series); @@ -458,7 +458,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable var nm = server.NodeManager!; nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore(); var series = Enumerable.Range(0, 6) - .Select(i => { var t = Epoch.AddTicks(10L * (i + 1)); return new DataValueSnapshot((double)i, StatusCodes.Good, t, t); }) + .Select(i => { var t = Epoch.AddTicks(10L * (i + 1)); return new DataValueSnapshot((double)i, StatusCodes.Good.Code, t, t); }) .ToArray(); nm.HistorianDataSource = new MicrosecondHistorianDataSource(series); @@ -516,7 +516,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable var (values, error, cp) = ReadRaw(nm, nodeId, DateTime.MinValue, Epoch.AddSeconds(100), max: 3, inboundCp: null); - error.StatusCode.Code.ShouldBe(StatusCodes.Good); + error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); values.ShouldBe([100d, 99d, 98d]); cp.ShouldNotBeNull(); source.ReverseWindowCount.ShouldBe(0); @@ -533,7 +533,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable // (end, start] = (Epoch+9s, Epoch+200s] — both edges exercised. var (values, error, cp) = ReadRaw(nm, nodeId, Epoch.AddSeconds(200), Epoch.AddSeconds(9), max: 0, inboundCp: null); - error.StatusCode.Code.ShouldBe(StatusCodes.Good); + error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); values.ShouldBe(Enumerable.Range(10, 191).Reverse().Select(i => (double)i)); cp.ShouldBeNull(); source.ReverseWindowCount.ShouldBe(0); @@ -583,7 +583,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable static DataValueSnapshot At(int year, double v) { var t = new DateTime(year, 1, 1, 0, 0, 0, DateTimeKind.Utc); - return new DataValueSnapshot(v, StatusCodes.Good, t, t); + return new DataValueSnapshot(v, StatusCodes.Good.Code, t, t); } var source = new ForwardOnlyHistorianDataSource([At(1990, 1), At(2000, 2)]); @@ -591,7 +591,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable var (values, error, cp) = ReadRaw(nm, nodeId, DateTime.MinValue, Epoch, max: 5, inboundCp: null); - error.StatusCode.Code.ShouldBe(StatusCodes.Good); + error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); values.ShouldBe([2d, 1d]); cp.ShouldBeNull(); source.ReadCount.ShouldBeLessThanOrEqualTo(20); @@ -611,7 +611,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable var (_, error, cp) = ReadRaw(nm, nodeId, Epoch.AddHours(1), Epoch.AddSeconds(-1), max: 2, inboundCp: null); - error.StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations); + error.StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations.Code); cp.ShouldBeNull(); source.ReverseWindowCount.ShouldBe(0); await host.DisposeAsync(); @@ -624,7 +624,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable { // 2000 samples 10 ms apart: a 1-minute first segment holds all of them, far over a budget of 64. var series = Enumerable.Range(0, 2000) - .Select(i => { var t = Epoch.AddMilliseconds(10L * i); return new DataValueSnapshot((double)i, StatusCodes.Good, t, t); }) + .Select(i => { var t = Epoch.AddMilliseconds(10L * i); return new DataValueSnapshot((double)i, StatusCodes.Good.Code, t, t); }) .ToArray(); var source = new ForwardOnlyHistorianDataSource(series); var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdDensity"); @@ -640,7 +640,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable while (cp is not null) { var (values, error, next) = ReadRaw(nm, nodeId, DateTime.MinValue, Epoch.AddSeconds(20), max: 20, inboundCp: cp); - error.StatusCode.Code.ShouldBe(StatusCodes.Good); + error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); collected.AddRange(values); cp = next; (++pages).ShouldBeLessThan(200); @@ -665,7 +665,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable { // 100,000 samples 100 ms apart (~2.8 h): far more than the default tie budget would ever let a page read. var series = Enumerable.Range(0, 100_000) - .Select(i => { var t = Epoch.AddMilliseconds(100L * i); return new DataValueSnapshot((double)i, StatusCodes.Good, t, t); }) + .Select(i => { var t = Epoch.AddMilliseconds(100L * i); return new DataValueSnapshot((double)i, StatusCodes.Good.Code, t, t); }) .ToArray(); var source = new ForwardOnlyHistorianDataSource(series); var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdDense689"); @@ -678,7 +678,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable { source.ResetCounts(); var (values, error, next) = ReadRaw(nm, nodeId, DateTime.MinValue, end, max: 3, inboundCp: cp); - error.StatusCode.Code.ShouldBe(StatusCodes.Good); + error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); values.ShouldNotBeEmpty(); // #692: every page is full (N = 3) — a resumed page used to net N − 1 after trimming its boundary sample. values.ShouldBe([expected, expected - 1d, expected - 2d], $"page {page} continues exactly"); @@ -726,7 +726,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable for (var k = 0; k < ties; k++) { var ts = new DateTime(t, DateTimeKind.Utc); - series.Add(new DataValueSnapshot((double)v++, StatusCodes.Good, ts, ts)); + series.Add(new DataValueSnapshot((double)v++, StatusCodes.Good.Code, ts, ts)); } } @@ -759,7 +759,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable break; } - error.StatusCode.Code.ShouldBe(StatusCodes.Good, $"seed {seed}, N = {max}, ms max {msMax}"); + error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code, $"seed {seed}, N = {max}, ms max {msMax}"); collected.AddRange(values); cp = next; (++pages).ShouldBeLessThan(2000, "paging must terminate, not loop"); @@ -793,7 +793,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable { source.ResetCounts(); var (values, error, next) = ReadRaw(nm, nodeId, DateTime.MinValue, Epoch.AddHours(1), max: 10, inboundCp: cp); - error.StatusCode.Code.ShouldBe(StatusCodes.Good); + error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); collected.AddRange(values); cp = next; page++; @@ -818,7 +818,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable var (values, error, _) = ReadRaw(nm, nodeId, DateTime.MaxValue, Epoch.AddSeconds(-1), max: 3, inboundCp: null); - error.StatusCode.Code.ShouldBe(StatusCodes.Good); + error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); values.ShouldBe([249d, 248d, 247d]); source.MaxRequestedEnd.ShouldBeLessThanOrEqualTo(now.AddDays(1).AddMilliseconds(1)); source.ReadCount.ShouldBeLessThanOrEqualTo(10); @@ -836,7 +836,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable var (values, error, _) = ReadRaw(nm, nodeId, Epoch.AddSeconds(10), Epoch.AddTicks(5_000), max: 100, inboundCp: null); - error.StatusCode.Code.ShouldBe(StatusCodes.Good); + error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); values.ShouldBe(Enumerable.Range(1, 10).Reverse().Select(i => (double)i)); // (Epoch+0.5ms, Epoch+10s]: no 0 await host.DisposeAsync(); } @@ -849,7 +849,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable DataValueSnapshot At(long ticks, double v) { var t = Epoch.AddTicks(ticks); - return new DataValueSnapshot(v, StatusCodes.Good, t, t); + return new DataValueSnapshot(v, StatusCodes.Good.Code, t, t); } var t0 = TimeSpan.TicksPerSecond; @@ -858,7 +858,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable var (values, error, _) = ReadRaw(nm, nodeId, Epoch.AddTicks(t0), Epoch.AddSeconds(-1), max: 10, inboundCp: null); - error.StatusCode.Code.ShouldBe(StatusCodes.Good); + error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); values.ShouldBe([1d, 0d]); await host.DisposeAsync(); } @@ -874,7 +874,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable var (values, error, _) = ReadRaw(nm, nodeId, Epoch.AddHours(1), Epoch.AddSeconds(-1), max: 100, inboundCp: null); - error.StatusCode.Code.ShouldBe(StatusCodes.Good); + error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); values.ShouldBe(Enumerable.Range(0, 8).Reverse().Select(i => (double)i)); await host.DisposeAsync(); } @@ -889,7 +889,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable for (var c = 0; c < 3; c++) { var t = Epoch.AddSeconds(5).AddMilliseconds(c); - for (var i = 0; i < 4; i++) series.Add(new DataValueSnapshot((double)v++, StatusCodes.Good, t, t)); + for (var i = 0; i < 4; i++) series.Add(new DataValueSnapshot((double)v++, StatusCodes.Good.Code, t, t)); } var source = new ForwardOnlyHistorianDataSource(series); @@ -898,7 +898,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable var (values, error, _) = ReadRaw(nm, nodeId, Epoch.AddHours(1), Epoch.AddSeconds(-1), max: 100, inboundCp: null); - error.StatusCode.Code.ShouldBe(StatusCodes.Good); + error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); values.ShouldBe(Enumerable.Range(0, 12).Reverse().Select(i => (double)i)); await host.DisposeAsync(); } @@ -916,7 +916,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable var (values, error, _) = ReadRaw(nm, nodeId, DateTime.MinValue, Epoch.AddSeconds(100), max: 3, inboundCp: null); - error.StatusCode.Code.ShouldBe(StatusCodes.Good); + error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); values.ShouldBe([100d, 99d, 98d]); await host.DisposeAsync(); } @@ -951,7 +951,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable do { var (values, error, nextCp) = ReadRaw(nm, nodeId, start, end, max, inboundCp: cp); - error.StatusCode.Code.ShouldBe(StatusCodes.Good); + error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); collected.AddRange(values); cp = nextCp; pageCount++; @@ -965,11 +965,48 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable /// Issue a single-node Raw HistoryRead and return that node's decoded sample values, error, /// and outbound continuation point. + [Fact] + public async Task A_cursor_cannot_cross_aliases_that_share_a_historian_tagname() + { + var (host, server) = await BootAsync(); + await using var ownedHost = host; + var nm = server.NodeManager!; + nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore(); + nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(10, 1)); + foreach (var realm in new[] { AddressSpaceRealm.Raw, AddressSpaceRealm.Uns }) + nm.EnsureVariable("same", null, "Same", "Double", false, realm, "WW.PV"); + var raw = nm.TryGetVariable("same", AddressSpaceRealm.Raw)!.NodeId; + var uns = nm.TryGetVariable("same", AddressSpaceRealm.Uns)!.NodeId; + var first = ReadRaw(nm, raw, Epoch, Epoch.AddHours(1), 2, null); + first.Cp.ShouldNotBeNull(); + ReadRaw(nm, uns, Epoch, Epoch.AddHours(1), 2, first.Cp).Error.StatusCode.ShouldBe(StatusCodes.BadContinuationPointInvalid); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Topology_replacement_invalidates_an_existing_cursor(bool rebuild) + { + var (host, server) = await BootAsync(); + await using var ownedHost = host; + var nm = server.NodeManager!; + nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore(); + nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(10, 1)); + nm.EnsureVariable("v", null, "Value", "Double", false, AddressSpaceRealm.Raw, "WW.PV"); + var node = nm.TryGetVariable("v", AddressSpaceRealm.Raw)!.NodeId; + var first = ReadRaw(nm, node, Epoch, Epoch.AddHours(1), 2, null); + first.Cp.ShouldNotBeNull(); + if (rebuild) nm.RebuildAddressSpace(); + else nm.RemoveVariableNode("v", AddressSpaceRealm.Raw).ShouldBeTrue(); + nm.EnsureVariable("v", null, "Value", "Double", false, AddressSpaceRealm.Raw, "WW.PV"); + ReadRaw(nm, node, Epoch, Epoch.AddHours(1), 2, first.Cp).Error.StatusCode.ShouldBe(StatusCodes.BadContinuationPointInvalid); + } + private static (IReadOnlyList Values, ServiceResult Error, byte[]? Cp) ReadRaw( OtOpcUaNodeManager nm, NodeId nodeId, DateTime start, DateTime end, uint max, byte[]? inboundCp) { var context = new OperationContext( - new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null); + new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null); var details = new ReadRawModifiedDetails { @@ -982,7 +1019,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable var nodesToRead = new List { - new() { NodeId = nodeId, ContinuationPoint = inboundCp }, + new() { NodeId = nodeId, ContinuationPoint = ByteString.From(inboundCp) }, }; var results = new List { null! }; var errors = new List { null! }; @@ -991,26 +1028,26 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable releaseContinuationPoints: false, nodesToRead, results, errors); var values = new List(); - if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho)) + if (results[0]?.HistoryData is { } ho && !ho.IsNull) { - var data = (HistoryData)ExtensionObject.ToEncodeable(ho); - foreach (var dv in data.DataValues) values.Add((double)dv.Value); + var data = (HistoryData)ExtensionObject.ToEncodeable(ho)!; + foreach (var dv in data.DataValues) values.Add((double)SdkValueBoundary.ToClr(dv.WrappedValue)!); } - return (values, errors[0], results[0]?.ContinuationPoint); + return (values, errors[0], results[0]?.ContinuationPoint.ToNullableArray()); } /// Issue a release-only HistoryRead (releaseContinuationPoints=true) for the node + point. private static void InvokeRelease(OtOpcUaNodeManager nm, NodeId nodeId, byte[] cp) { var context = new OperationContext( - new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null); + new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null); var details = new ReadRawModifiedDetails { ReturnBounds = false, StartTime = Epoch, EndTime = Epoch.AddHours(1), NumValuesPerNode = 100, IsReadModified = false, }; - var nodesToRead = new List { new() { NodeId = nodeId, ContinuationPoint = cp } }; + var nodesToRead = new List { new() { NodeId = nodeId, ContinuationPoint = ByteString.From(cp) } }; var results = new List { null! }; var errors = new List { null! }; @@ -1023,7 +1060,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable .Select(i => { var t = Epoch.AddSeconds((long)i * stepSeconds); - return new DataValueSnapshot((double)i, StatusCodes.Good, t, t); + return new DataValueSnapshot((double)i, StatusCodes.Good.Code, t, t); }) .ToArray(); @@ -1040,17 +1077,17 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable for (var i = 0; i < distinctBefore; i++, second++) { var t = Epoch.AddSeconds(second); - samples.Add(new DataValueSnapshot((double)value++, StatusCodes.Good, t, t)); + samples.Add(new DataValueSnapshot((double)value++, StatusCodes.Good.Code, t, t)); } var tieT = Epoch.AddSeconds(second++); // the single shared timestamp for the whole cluster for (var i = 0; i < tieCount; i++) - samples.Add(new DataValueSnapshot((double)value++, StatusCodes.Good, tieT, tieT)); + samples.Add(new DataValueSnapshot((double)value++, StatusCodes.Good.Code, tieT, tieT)); for (var i = 0; i < distinctAfter; i++, second++) { var t = Epoch.AddSeconds(second); - samples.Add(new DataValueSnapshot((double)value++, StatusCodes.Good, t, t)); + samples.Add(new DataValueSnapshot((double)value++, StatusCodes.Good.Code, t, t)); } return samples.ToArray(); @@ -1176,7 +1213,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable var rows = new List(); if (emitBoundaryRow) rows.Add(prior is null - ? new DataValueSnapshot(0d, StatusCodes.BadNoData, stamp, stamp) + ? new DataValueSnapshot(0d, StatusCodes.BadNoData.Code, stamp, stamp) : new DataValueSnapshot(prior.Value, prior.StatusCode, stamp, stamp)); rows.AddRange(series.Where(s => Ts(s) >= startUtc && Ts(s) <= endUtc)); var page = maxValuesPerNode == 0 ? rows : rows.Take((int)maxValuesPerNode).ToList(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadRawCapFuzzTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadRawCapFuzzTests.cs index 6a9e78cd..904e682d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadRawCapFuzzTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadRawCapFuzzTests.cs @@ -102,7 +102,7 @@ public sealed class NodeManagerHistoryReadRawCapFuzzTests(BoundsServerFixture fx Count("oracle refused"); continue; } - truthError.StatusCode.Code.ShouldBe(StatusCodes.Good, $"oracle: {tag}"); + truthError.StatusCode.Code.ShouldBe(StatusCodes.Good.Code, $"oracle: {tag}"); truthCp.ShouldBeNull($"oracle (no cap, N = 0) is one page: {tag}"); if (!bounds) Render(truth).ShouldBe(Render(Oracle(series, start, end, direction, resolution)), $"oracle vs series: {tag}"); @@ -124,8 +124,8 @@ public sealed class NodeManagerHistoryReadRawCapFuzzTests(BoundsServerFixture fx // A boundary tie cluster as large as the cap leaves a resumed page no room: the server steps past it with one // empty page carrying a continuation point (the documented #694 ceiling behaviour) — the one empty page allowed. var oversizedClusters = cap > 0 - ? truth.Where(v => !(v.Value is null && v.StatusCode.Code == StatusCodes.BadBoundNotFound)) - .GroupBy(v => v.SourceTimestamp.Ticks).Count(g => g.Count() >= Math.Max(2, cap - 1)) + ? truth.Where(v => !(SdkValueBoundary.ToClr(v.WrappedValue)! is null && v.StatusCode.Code == StatusCodes.BadBoundNotFound)) + .GroupBy(v => v.SourceTimestamp.ToDateTime().Ticks).Count(g => g.Count() >= Math.Max(2, cap - 1)) : 0; // The read under test. @@ -163,7 +163,7 @@ public sealed class NodeManagerHistoryReadRawCapFuzzTests(BoundsServerFixture fx continue; } - status.Code.ShouldBe(StatusCodes.Good, $"{tag}"); + status.Code.ShouldBe(StatusCodes.Good.Code, $"{tag}"); Render(collected).ShouldBe(Render(truth), $"paged ≠ unpaged: {tag} pages {Sizes(pages)}"); if (cap > 0) { @@ -179,8 +179,8 @@ public sealed class NodeManagerHistoryReadRawCapFuzzTests(BoundsServerFixture fx { if (pages[i].Count > 0) continue; // Allowed only as the step past a boundary tie cluster the cap cannot page within. - var previousLast = i > 0 && pages[i - 1].Count > 0 ? pages[i - 1][^1].SourceTimestamp.Ticks : (long?)null; - var clusterAtBoundary = previousLast is { } t ? truth.Count(v => v.SourceTimestamp.Ticks == t) : 0; + var previousLast = i > 0 && pages[i - 1].Count > 0 ? pages[i - 1][^1].SourceTimestamp.ToDateTime().Ticks : (long?)null; + var clusterAtBoundary = previousLast is { } t ? truth.Count(v => v.SourceTimestamp.ToDateTime().Ticks == t) : 0; (cap > 0 && clusterAtBoundary >= Math.Max(2, cap - 1)).ShouldBeTrue( $"an empty page with a continuation point (page {i}, boundary cluster {clusterAtBoundary}): {tag} pages {Sizes(pages)}"); Count("empty step over an oversized tie cluster"); @@ -259,7 +259,7 @@ public sealed class NodeManagerHistoryReadRawCapFuzzTests(BoundsServerFixture fx { var ts = Trunc(new DateTime(t, DateTimeKind.Utc), resolution); var status = rng.Next(12) == 0 ? StatusCodes.Bad : StatusCodes.Good; - list.Add(new DataValueSnapshot(status == StatusCodes.Bad ? null : (double)v, status, ts, ts)); + list.Add(new DataValueSnapshot(status == StatusCodes.Bad.Code ? null : (double)v, status.Code, ts, ts)); v++; } } @@ -302,13 +302,13 @@ public sealed class NodeManagerHistoryReadRawCapFuzzTests(BoundsServerFixture fx Direction.Reverse => series.Where(x => Ts(x) > e && Ts(x) <= s).Reverse(), _ => series.Where(x => Ts(x) <= e).Reverse(), }; - return window.Select(x => new DataValue(new Variant(x.Value), new StatusCode(x.StatusCode), Ts(x), Ts(x))).ToList(); + return window.Select(x => new DataValue(SdkValueBoundary.FromClr(x.Value), new StatusCode(x.StatusCode), Ts(x), Ts(x))).ToList(); } /// The largest tie cluster among the returned stored values (a bound placeholder is not a stored value). private static int LargestTieCluster(IReadOnlyList truth) => - truth.Where(v => !(v.Value is null && v.StatusCode.Code == StatusCodes.BadBoundNotFound)) - .GroupBy(v => v.SourceTimestamp.Ticks).Select(g => g.Count()).DefaultIfEmpty(0).Max(); + truth.Where(v => !(SdkValueBoundary.ToClr(v.WrappedValue)! is null && v.StatusCode.Code == StatusCodes.BadBoundNotFound)) + .GroupBy(v => v.SourceTimestamp.ToDateTime().Ticks).Select(g => g.Count()).DefaultIfEmpty(0).Max(); /// The most samples within one millisecond anywhere in the series (the walk-back's refusal rule). private static int MillisecondMax(List series) @@ -334,24 +334,24 @@ public sealed class NodeManagerHistoryReadRawCapFuzzTests(BoundsServerFixture fx private static string Sizes(IEnumerable> pages) => "[" + string.Join(",", pages.Select(p => p.Count)) + "]"; private static string Render(IEnumerable values) => - string.Join(" | ", values.Select(v => $"{v.SourceTimestamp.Ticks}={v.Value ?? "null"}/{v.StatusCode.Code:X8}")); + string.Join(" | ", values.Select(v => $"{v.SourceTimestamp.ToDateTime().Ticks}={SdkValueBoundary.ToClr(v.WrappedValue)! ?? "null"}/{v.StatusCode.Code:X8}")); private (IReadOnlyList Values, ServiceResult Error, byte[]? Cp) Read(DateTime start, DateTime end, uint max, bool bounds, byte[]? cp) { - var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null); + var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null); var details = new ReadRawModifiedDetails { StartTime = start, EndTime = end, NumValuesPerNode = max, ReturnBounds = bounds, IsReadModified = false, }; - var nodesToRead = new List { new() { NodeId = fx.NodeId, ContinuationPoint = cp } }; + var nodesToRead = new List { new() { NodeId = fx.NodeId, ContinuationPoint = ByteString.From(cp) } }; var results = new List { null! }; var errors = new List { null! }; fx.Nm.HistoryRead(context, details, TimestampsToReturn.Both, releaseContinuationPoints: false, nodesToRead, results, errors); var values = new List(); - if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho)) - values.AddRange(((HistoryData)ExtensionObject.ToEncodeable(ho)).DataValues); - return (values, errors[0], results[0]?.ContinuationPoint); + if (results[0]?.HistoryData is { } ho && !ho.IsNull) + values.AddRange(((HistoryData)ExtensionObject.ToEncodeable(ho)!).DataValues); + return (values, errors[0], results[0]?.ContinuationPoint.ToNullableArray()); } /// diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadTests.cs index 810d2df1..c2122f25 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadTests.cs @@ -46,7 +46,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable var src = DateTime.UtcNow.AddSeconds(-5); var srv = DateTime.UtcNow; fake.RawResult = new HistorianRead( - new[] { new DataValueSnapshot(42.5f, StatusCodes.Good, src, srv) }, null); + new[] { new DataValueSnapshot(42.5f, StatusCodes.Good.Code, src, srv) }, null); var start = DateTime.UtcNow.AddHours(-1); var end = DateTime.UtcNow; @@ -68,13 +68,13 @@ public sealed class NodeManagerHistoryReadTests : IDisposable fake.LastEnd.ShouldBe(end); fake.LastMaxValues.ShouldBe(101u, "the page plus one lookahead row (#692) — it tells the server whether more follows"); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); - results[0].StatusCode.Code.ShouldBe(StatusCodes.Good); - var data = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); + results[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); + var data = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)!; data.DataValues.Count.ShouldBe(1); var dv = data.DataValues[0]; - dv.Value.ShouldBe(42.5f); - dv.StatusCode.Code.ShouldBe(StatusCodes.Good); + SdkValueBoundary.ToClr(dv.WrappedValue)!.ShouldBe(42.5f); + dv.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); dv.SourceTimestamp.ShouldBe(src); dv.ServerTimestamp.ShouldBe(srv); @@ -140,10 +140,10 @@ public sealed class NodeManagerHistoryReadTests : IDisposable var (results, errors) = InvokeHistoryRead(server, nm, details, nodeId); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); - results[0].StatusCode.Code.ShouldBe(StatusCodes.GoodNoData); - var data = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData); - data.DataValues.ShouldBeEmpty(); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); + results[0].StatusCode.Code.ShouldBe(StatusCodes.GoodNoData.Code); + var data = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)!; + data.DataValues.Count.ShouldBe(0); await host.DisposeAsync(); } @@ -174,7 +174,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable var (_, errors) = InvokeHistoryRead(server, nm, details, nodeId); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); fake.LastCall.ShouldBeNull(); // source never reached await host.DisposeAsync(); @@ -208,14 +208,14 @@ public sealed class NodeManagerHistoryReadTests : IDisposable var (results, errors) = InvokeHistoryRead(server, nm, details, nodeId); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); - results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); + results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); fake.LastCall.ShouldBeNull(); // the raw read is never served in place of a modified one // The Null default (no historian configured) answers the same. nm.HistorianDataSource = NullHistorianDataSource.Instance; InvokeHistoryRead(server, nm, details, nodeId).Item2[0].StatusCode.Code - .ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + .ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); await host.DisposeAsync(); } @@ -239,7 +239,8 @@ public sealed class NodeManagerHistoryReadTests : IDisposable StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0, // ms - AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_Average }, + AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true }, + AggregateType = new List { ObjectIds.AggregateFunction_Average }, }; InvokeHistoryRead(server, nm, details, nodeId); @@ -272,12 +273,13 @@ public sealed class NodeManagerHistoryReadTests : IDisposable EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0, // StandardDeviationSample is a real OPC UA aggregate we do not serve. - AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_StandardDeviationSample }, + AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true }, + AggregateType = new List { ObjectIds.AggregateFunction_StandardDeviationSample }, }; var (_, errors) = InvokeHistoryRead(server, nm, details, nodeId); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported.Code); fake.LastCall.ShouldBeNull(); await host.DisposeAsync(); @@ -310,12 +312,13 @@ public sealed class NodeManagerHistoryReadTests : IDisposable StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0, - AggregateType = new NodeIdCollection { aggregate }, + AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true }, + AggregateType = new List { aggregate }, }; // Average is mapped by the server but not in this backend's set ⇒ refused, no call. InvokeHistoryRead(server, nm, Details(ObjectIds.AggregateFunction_Average), nodeId).Item2[0].StatusCode.Code - .ShouldBe(StatusCodes.BadAggregateNotSupported); + .ShouldBe(StatusCodes.BadAggregateNotSupported.Code); fake.LastCall.ShouldBeNull(); // Positive control: a member of the set reaches the backend. @@ -326,7 +329,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable // No historian: the Null default serves no aggregate. nm.HistorianDataSource = NullHistorianDataSource.Instance; InvokeHistoryRead(server, nm, Details(ObjectIds.AggregateFunction_TimeAverage), nodeId).Item2[0].StatusCode.Code - .ShouldBe(StatusCodes.BadAggregateNotSupported); + .ShouldBe(StatusCodes.BadAggregateNotSupported.Code); await host.DisposeAsync(); } @@ -354,13 +357,14 @@ public sealed class NodeManagerHistoryReadTests : IDisposable StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0, - AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_StandardDeviationPopulation }, + AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true }, + AggregateType = new List { ObjectIds.AggregateFunction_StandardDeviationPopulation }, }, nodeId); StatusCode.IsBad(errors[0].StatusCode).ShouldBeFalse($"got {errors[0].StatusCode}"); fake.LastCall.ShouldBe("Processed"); fake.LastAggregate.ShouldBe(HistoryAggregateType.StandardDeviationPopulation); - var diagnostics = (CustomNodeManager2)nm.Server.DiagnosticsNodeManager; + var diagnostics = (AsyncCustomNodeManager)nm.Server.DiagnosticsNodeManager; var folder = diagnostics.FindPredefinedNode(ObjectIds.HistoryServerCapabilities_AggregateFunctions)!; AggregateFunctionMap.ByNodeId.Keys.Count(id => folder.ReferenceExists(ReferenceTypeIds.Organizes, false, id)) .ShouldBe(AggregateFunctionMap.ByNodeId.Count); @@ -397,7 +401,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable var (_, errors) = InvokeHistoryRead(server, nm, new ReadProcessedDetails { StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0, - AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_TimeAverage }, + AggregateType = new List { ObjectIds.AggregateFunction_TimeAverage }, AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = false, TreatUncertainAsBad = treatUncertainAsBad, @@ -405,7 +409,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable }, }, nodeId); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateConfigurationRejected); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateConfigurationRejected.Code); fake.LastCall.ShouldBeNull(); await host.DisposeAsync(); @@ -432,7 +436,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable var (_, errors) = InvokeHistoryRead(server, nm, new ReadProcessedDetails { StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0, - AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_TimeAverage }, + AggregateType = new List { ObjectIds.AggregateFunction_TimeAverage }, AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = useServerDefaults, TreatUncertainAsBad = treatUncertainAsBad, @@ -455,7 +459,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable [Fact] public void The_server_aggregate_defaults_deliberately_diverge_from_the_SDK_on_TreatUncertainAsBad() { - var sdk = new Opc.Ua.Server.AggregateManager(null!).GetDefaultConfiguration(null); + var sdk = new Opc.Ua.Server.AggregateManager(null!).GetDefaultConfiguration(NodeId.Null); var ours = ServerAggregateConfiguration.Defaults; ours.TreatUncertainAsBad.ShouldBeFalse(); ours.PercentDataBad.ShouldBe((byte)100); @@ -476,7 +480,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable public async Task The_booted_SDK_AggregateManager_default_is_our_TreatUncertainAsBad_false() { var (host, server) = await BootAsync(); - var cfg = server.CurrentInstance.AggregateManager.GetDefaultConfiguration(null); + var cfg = server.CurrentInstance.AggregateManager.GetDefaultConfiguration(NodeId.Null); cfg.TreatUncertainAsBad.ShouldBeFalse(); cfg.PercentDataBad.ShouldBe(ServerAggregateConfiguration.PercentDataBad); cfg.PercentDataGood.ShouldBe(ServerAggregateConfiguration.PercentDataGood); @@ -500,7 +504,8 @@ public sealed class NodeManagerHistoryReadTests : IDisposable InvokeHistoryRead(server, nm, new ReadProcessedDetails { StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0, - AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_Interpolative }, + AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true }, + AggregateType = new List { ObjectIds.AggregateFunction_Interpolative }, }, nodeId); fake.LastCall.ShouldBe("Processed"); @@ -528,7 +533,8 @@ public sealed class NodeManagerHistoryReadTests : IDisposable InvokeHistoryRead(server, nm, new ReadProcessedDetails { StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0, - AggregateType = new NodeIdCollection { aggregateId }, + AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true }, + AggregateType = new List { aggregateId }, }, nodeId); fake.LastCall.ShouldBe("Processed"); @@ -551,10 +557,11 @@ public sealed class NodeManagerHistoryReadTests : IDisposable var (_, errors) = InvokeHistoryRead(server, nm, new ReadProcessedDetails { StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0, - AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_PercentGood }, + AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true }, + AggregateType = new List { ObjectIds.AggregateFunction_PercentGood }, }, nodeId); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported.Code); await host.DisposeAsync(); } @@ -573,10 +580,11 @@ public sealed class NodeManagerHistoryReadTests : IDisposable var (_, errors) = InvokeHistoryRead(server, nm, new ReadProcessedDetails { StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0, - AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_Total }, + AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true }, + AggregateType = new List { ObjectIds.AggregateFunction_Total }, }, nodeId); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations.Code); await host.DisposeAsync(); } @@ -597,7 +605,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable var t2 = DateTime.UtcNow.AddMinutes(-1); var details = new ReadAtTimeDetails { - ReqTimes = new DateTimeCollection { t1, t2 }, + ReqTimes = new List { t1, t2 }, }; InvokeHistoryRead(server, nm, details, nodeId); @@ -638,7 +646,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable var nodeIds = paths.Select((p, i) => nm.TryGetVariable(p, realms[i])!.NodeId).ToArray(); var (_, errors) = InvokeHistoryRead(server, nm, - new ReadAtTimeDetails { ReqTimes = new DateTimeCollection { DateTime.UtcNow.AddMinutes(-1) } }, nodeIds); + new ReadAtTimeDetails { ReqTimes = new List { DateTime.UtcNow.AddMinutes(-1) } }, nodeIds); errors.ShouldAllBe(e => StatusCode.IsGood(e.StatusCode)); fake.Hints["R.Flag"].ShouldBe(TypeCode.Boolean); @@ -707,7 +715,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable // Per-tagname fake: A.PV returns one sample; B.PV throws. var perTagFake = new PerTagnameHistorianDataSource(tagname => tagname == "A.PV" - ? Task.FromResult(new HistorianRead(new[] { new DataValueSnapshot(1.0f, StatusCodes.Good, src, srv) }, null)) + ? Task.FromResult(new HistorianRead(new[] { new DataValueSnapshot(1.0f, StatusCodes.Good.Code, src, srv) }, null)) : throw new InvalidOperationException("backend boom for B.PV")); nm.HistorianDataSource = perTagFake; @@ -725,20 +733,20 @@ public sealed class NodeManagerHistoryReadTests : IDisposable var (results, errors) = InvokeHistoryRead(server, nm, details, goodNodeId, badNodeId); // Good node (index 0): should have a Good status + one sample. - errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); - results[0].StatusCode.Code.ShouldBe(StatusCodes.Good); - var data = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); + results[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); + var data = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)!; data.DataValues.Count.ShouldBe(1); - data.DataValues[0].Value.ShouldBe(1.0f); + SdkValueBoundary.ToClr(data.DataValues[0].WrappedValue)!.ShouldBe(1.0f); // Bad node (index 1): errors slot must be Bad; results slot must NOT carry any HistoryData // (no data was projected into it). The base may pre-seed results[i] to a default-constructed // SdkHistoryReadResult whose StatusCode is 0 (Good) — that's fine for the result field, but the // distinguishing signal is the error slot AND the absence of HistoryData from the good node. StatusCode.IsBad(errors[1].StatusCode).ShouldBeTrue(); - errors[1].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + errors[1].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); // HistoryData must be null/empty — the good node's sample MUST NOT have leaked into slot 1. - (results[1].HistoryData == null || ExtensionObject.IsNull(results[1].HistoryData)).ShouldBeTrue(); + results[1].HistoryData.IsNull.ShouldBeTrue(); await host.DisposeAsync(); } @@ -770,13 +778,47 @@ public sealed class NodeManagerHistoryReadTests : IDisposable var (results, errors) = InvokeHistoryRead(server, nm, details, nodeId); StatusCode.IsBad(errors[0].StatusCode).ShouldBeTrue(); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); // The result slot was never filled with a GoodNoData success. (results[0].StatusCode.Code == StatusCodes.GoodNoData).ShouldBeFalse(); await host.DisposeAsync(); } + [Fact] + public async Task Canceling_the_SDK_request_cancels_an_inflight_backend_read() + { + var (host, server) = await BootAsync(); + await using var ownedHost = host; + var nm = server.NodeManager!; + nm.HistoryReadDeadline = TimeSpan.Zero; + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + nm.HistorianDataSource = new RecordingHistorianDataSource + { + RawRead = async ct => + { + entered.SetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, ct); + return Empty(); + }, + }; + nm.EnsureVariable("cancel", null, "Cancel", "Double", false, AddressSpaceRealm.Raw, "cancel.tag"); + var node = nm.TryGetVariable("cancel", AddressSpaceRealm.Raw)!.NodeId; + using var lifetime = new RequestLifetime(); + using var context = new OperationContext(new RequestHeader(), null, RequestType.HistoryRead, lifetime); + var results = new List { null! }; + var errors = new List { null! }; + var read = Task.Run(() => nm.HistoryRead(context, new ReadRawModifiedDetails + { + StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, NumValuesPerNode = 10, + }, TimestampsToReturn.Both, false, [new HistoryReadValueId { NodeId = node }], results, errors), Ct); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), Ct); + lifetime.TryCancel(StatusCodes.BadRequestCancelledByClient).ShouldBeTrue(); + await read.WaitAsync(TimeSpan.FromSeconds(5), Ct); + StatusCode.IsBad(errors[0].StatusCode).ShouldBeTrue(); + results[0].ContinuationPoint.IsEmpty.ShouldBeTrue(); + } + /// Invoke the node manager's public HistoryRead with a single node, returning the filled /// results + errors. Uses a session-less (the /// (RequestHeader, SecureChannelContext, RequestType, IUserIdentity) ctor) — HistoryRead's @@ -791,7 +833,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable OtOpcUaSdkServer server, OtOpcUaNodeManager nm, HistoryReadDetails details, params NodeId[] nodeIds) { var context = new OperationContext( - new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null); + new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null); var nodesToRead = nodeIds.Select(id => new HistoryReadValueId { NodeId = id }).ToList(); var results = Enumerable.Repeat(null!, nodeIds.Length).ToList(); @@ -825,13 +867,14 @@ public sealed class NodeManagerHistoryReadTests : IDisposable HistoryReadDetails details = kind switch { "Raw" => new ReadRawModifiedDetails { IsReadModified = false, ReturnBounds = false, StartTime = t, EndTime = t.AddSeconds(10) }, - "AtTime" => new ReadAtTimeDetails { ReqTimes = new DateTimeCollection { t } }, + "AtTime" => new ReadAtTimeDetails { ReqTimes = new List { t } }, _ => new ReadProcessedDetails { StartTime = t, EndTime = t.AddSeconds(10), ProcessingInterval = 1000, - AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_Average } } + AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true }, + AggregateType = new List { ObjectIds.AggregateFunction_Average } } }; var (results, errors) = InvokeHistoryRead(server, nm, details, nm.TryGetVariable("eq/comm")!.NodeId); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication); - results[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication.Code); + results[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication.Code); } finally { await host.DisposeAsync(); } } @@ -850,20 +893,20 @@ public sealed class NodeManagerHistoryReadTests : IDisposable { var nm = server.NodeManager!; var t = DateTime.UtcNow.AddMinutes(-1); - var value = new HistorianRead([new DataValueSnapshot(1d, StatusCodes.Good, t, t)], null); + var value = new HistorianRead([new DataValueSnapshot(1d, StatusCodes.Good.Code, t, t)], null); nm.HistorianDataSource = new RecordingHistorianDataSource { RawResult = value, AtTimeResult = value }; nm.EnsureVariable("eq/typed", null, "Typed", dataType, false, historianTagname: "T", realm: AddressSpaceRealm.Uns); HistoryReadDetails details = kind == "Raw" ? new ReadRawModifiedDetails { IsReadModified = false, ReturnBounds = false, StartTime = t, EndTime = t.AddSeconds(10) } - : new ReadAtTimeDetails { ReqTimes = new DateTimeCollection { t } }; + : new ReadAtTimeDetails { ReqTimes = new List { t } }; var (results, errors) = InvokeHistoryRead(server, nm, details, nm.TryGetVariable("eq/typed")!.NodeId); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); - var actual = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)).DataValues.Single(); - if (dataType == "Int32") actual.Value.ShouldBeOfType().ShouldBe(1); - if (dataType == "Boolean") actual.Value.ShouldBeOfType().ShouldBeTrue(); - if (dataType == "Float") actual.Value.ShouldBeOfType().ShouldBe(1f); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); + var actual = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)!).DataValues.ToArray()!.Single(); + if (dataType == "Int32") SdkValueBoundary.ToClr(actual.WrappedValue)!.ShouldBeOfType().ShouldBe(1); + if (dataType == "Boolean") SdkValueBoundary.ToClr(actual.WrappedValue)!.ShouldBeOfType().ShouldBeTrue(); + if (dataType == "Float") SdkValueBoundary.ToClr(actual.WrappedValue)!.ShouldBeOfType().ShouldBe(1f); actual.SourceTimestamp.ShouldBe(t); - actual.StatusCode.Code.ShouldBe(StatusCodes.Good); + actual.StatusCode.Code.ShouldBe(StatusCodes.Good.Code); } finally { await host.DisposeAsync(); } } @@ -879,12 +922,12 @@ public sealed class NodeManagerHistoryReadTests : IDisposable var nm = server.NodeManager!; var t = DateTime.UtcNow.AddMinutes(-1); nm.EnsureVariable("eq/mismatch", null, "Mismatch", type, false, historianTagname: "T", realm: AddressSpaceRealm.Uns); - nm.HistorianDataSource = new RecordingHistorianDataSource { RawResult = new HistorianRead([new(input, StatusCodes.Good, t, t)], null) }; + nm.HistorianDataSource = new RecordingHistorianDataSource { RawResult = new HistorianRead([new(input, StatusCodes.Good.Code, t, t)], null) }; var (results, _) = InvokeHistoryRead(server, nm, new ReadRawModifiedDetails { IsReadModified = false, ReturnBounds = false, StartTime = t, EndTime = t.AddSeconds(10) }, nm.TryGetVariable("eq/mismatch")!.NodeId); - var value = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)).DataValues.Single(); - value.Value.ShouldBeNull(); - value.StatusCode.Code.ShouldBe(StatusCodes.BadTypeMismatch); + var value = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)!).DataValues.ToArray()!.Single(); + SdkValueBoundary.ToClr(value.WrappedValue)!.ShouldBeNull(); + value.StatusCode.Code.ShouldBe(StatusCodes.BadTypeMismatch.Code); value.SourceTimestamp.ShouldBe(t); value.ServerTimestamp.ShouldBe(t); } @@ -902,17 +945,17 @@ public sealed class NodeManagerHistoryReadTests : IDisposable var serverTime = t.AddSeconds(1); nm.EnsureVariable("eq/array", null, "Array", "Int32", false, historianTagname: "T", realm: AddressSpaceRealm.Raw, isArray: true, arrayLength: 2); nm.HistorianDataSource = new RecordingHistorianDataSource { RawResult = new HistorianRead([ - new(new int[] { 1, 2 }, StatusCodes.Uncertain, t, serverTime), - new(null, StatusCodes.BadNoCommunication, t.AddSeconds(1), serverTime)], null) }; + new(new int[] { 1, 2 }, StatusCodes.Uncertain.Code, t, serverTime), + new(null, StatusCodes.BadNoCommunication.Code, t.AddSeconds(1), serverTime)], null) }; var (results, _) = InvokeHistoryRead(server, nm, new ReadRawModifiedDetails { IsReadModified = false, ReturnBounds = false, StartTime = t, EndTime = t.AddSeconds(10) }, nm.TryGetVariable("eq/array", AddressSpaceRealm.Raw)!.NodeId); - var values = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)).DataValues; - values[0].Value.ShouldBeOfType().ShouldBe([1, 2]); - values[0].StatusCode.Code.ShouldBe(StatusCodes.Uncertain); + var values = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)!).DataValues; + SdkValueBoundary.ToClr(values[0].WrappedValue)!.ShouldBeOfType().ShouldBe([1, 2]); + values[0].StatusCode.Code.ShouldBe(StatusCodes.Uncertain.Code); values[0].SourceTimestamp.ShouldBe(t); values[0].ServerTimestamp.ShouldBe(serverTime); - values[1].Value.ShouldBeNull(); - values[1].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication); + SdkValueBoundary.ToClr(values[1].WrappedValue)!.ShouldBeNull(); + values[1].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication.Code); } finally { await host.DisposeAsync(); } } @@ -926,11 +969,12 @@ public sealed class NodeManagerHistoryReadTests : IDisposable var nm = server.NodeManager!; var t = DateTime.UtcNow.AddMinutes(-1); nm.EnsureVariable("eq/count", null, "Count", "Boolean", false, historianTagname: "T", realm: AddressSpaceRealm.Uns); - nm.HistorianDataSource = new RecordingHistorianDataSource { ProcessedResult = new HistorianRead([new(3d, StatusCodes.Good, t, t)], null) }; + nm.HistorianDataSource = new RecordingHistorianDataSource { ProcessedResult = new HistorianRead([new(3d, StatusCodes.Good.Code, t, t)], null) }; var (results, _) = InvokeHistoryRead(server, nm, new ReadProcessedDetails { StartTime = t, EndTime = t.AddSeconds(10), - ProcessingInterval = 1000, AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_Count } }, nm.TryGetVariable("eq/count")!.NodeId); - var value = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)).DataValues.Single(); - value.Value.ShouldBeOfType().ShouldBe(3d); + ProcessingInterval = 1000, AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true }, + AggregateType = new List { ObjectIds.AggregateFunction_Count } }, nm.TryGetVariable("eq/count")!.NodeId); + var value = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)!).DataValues.ToArray()!.Single(); + SdkValueBoundary.ToClr(value.WrappedValue)!.ShouldBeOfType().ShouldBe(3d); } finally { await host.DisposeAsync(); } } @@ -941,6 +985,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable /// a configured result (or throws when is set). private sealed class RecordingHistorianDataSource : IHistorianDataSource { + public Func>? RawRead { get; init; } public bool ThrowOnRead { get; init; } public Exception? ReadException { get; init; } public bool AggregateNotSupported { get; init; } @@ -971,7 +1016,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable LastStart = startUtc; LastEnd = endUtc; LastMaxValues = maxValuesPerNode; - return Task.FromResult(RawResult); + return RawRead?.Invoke(cancellationToken) ?? Task.FromResult(RawResult); } public Task ReadProcessedAsync( diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerKeyUsabilityGateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerKeyUsabilityGateTests.cs index 9bf2741c..00c08f63 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerKeyUsabilityGateTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerKeyUsabilityGateTests.cs @@ -62,11 +62,11 @@ public sealed class NodeManagerKeyUsabilityGateTests : IDisposable var nodeId = Writable(nm, "Plant/Modbus/dev1/Setpoint"); var identity = KeyIdentity(OpcUaDataPlaneRoles.WriteOperate); - Write(nm, identity, nodeId, 1f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + Write(nm, identity, nodeId, 1f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); clock.Advance(TimeSpan.FromMinutes(20)); - Write(nm, identity, nodeId, 2f)[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + Write(nm, identity, nodeId, 2f)[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); gateway.Writes.Count.ShouldBe(1); state.Current.GenerationId.ShouldBe(1); // no deployment moved anything — only the clock did } @@ -90,13 +90,13 @@ public sealed class NodeManagerKeyUsabilityGateTests : IDisposable var nodeId = Writable(nm, "Plant/Modbus/dev1/Setpoint"); var identity = KeyIdentity(OpcUaDataPlaneRoles.WriteOperate); - Write(nm, identity, nodeId, 1f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + Write(nm, identity, nodeId, 1f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); var disabled = Key(expiresUtc: null); disabled.Enabled = false; state.Apply(Cluster, "rev-2", [disabled], [], NoScopes); - Write(nm, identity, nodeId, 2f)[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + Write(nm, identity, nodeId, 2f)[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); gateway.Writes.Count.ShouldBe(1); } finally { await host.DisposeAsync(); } @@ -124,9 +124,9 @@ public sealed class NodeManagerKeyUsabilityGateTests : IDisposable new AnonymousIdentityToken(), [OpcUaDataPlaneRoles.WriteOperate], OpcUaPrincipals.Anonymous, apiKeyId: null); - Write(nm, anonymous, nodeId, 1f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + Write(nm, anonymous, nodeId, 1f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); clock.Advance(TimeSpan.FromDays(400)); - Write(nm, anonymous, nodeId, 2f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + Write(nm, anonymous, nodeId, 2f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code); gateway.Writes.Count.ShouldBe(2); } @@ -158,13 +158,13 @@ public sealed class NodeManagerKeyUsabilityGateTests : IDisposable UserIdentity = KeyIdentity(OpcUaDataPlaneRoles.AlarmAck), }; - condition.OnAcknowledge!(ctx, condition, Guid.NewGuid().ToByteArray(), new LocalizedText("1")) + condition.OnAcknowledge!(ctx, condition, ByteString.From(Guid.NewGuid().ToByteArray()), new LocalizedText("1")) .ShouldBe(ServiceResult.Good); clock.Advance(TimeSpan.FromMinutes(20)); - condition.OnAcknowledge!(ctx, condition, Guid.NewGuid().ToByteArray(), new LocalizedText("2")) - .StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + condition.OnAcknowledge!(ctx, condition, ByteString.From(Guid.NewGuid().ToByteArray()), new LocalizedText("2")) + .StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); routed.Count.ShouldBe(1); } finally { await host.DisposeAsync(); } @@ -217,14 +217,14 @@ public sealed class NodeManagerKeyUsabilityGateTests : IDisposable OtOpcUaNodeManager nm, IUserIdentity identity, NodeId nodeId, object value) { var context = new OperationContext( - new RequestHeader(), secureChannelContext: null, RequestType.Write, identity); + new RequestHeader(), secureChannelContext: null, RequestType.Write, requestLifetime: RequestLifetime.None, identity); var writes = new List { new() { NodeId = nodeId, AttributeId = Attributes.Value, - Value = new DataValue(new Variant(value)) { StatusCode = StatusCodes.Good }, + Value = new DataValue(SdkValueBoundary.FromClr(value), StatusCodes.Good), }, }; var errors = Enumerable.Repeat(null!, writes.Count).ToList(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs index 9d257d66..da4e540a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs @@ -137,7 +137,7 @@ public sealed class NodeManagerLockTimeBenchmarkTests(ITestOutputHelper output) // A Server-object (all-events) subscriber, so the per-condition monitoring unit is in play. nm.SubscribeToAllEvents( - new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.CreateMonitoredItems, identity: null), + new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.CreateMonitoredItems, requestLifetime: RequestLifetime.None, identity: null), 1, new StubEventItem(), unsubscribe: false); nm.ConditionEventsMonitored("alm-0", AddressSpaceRealm.Uns).ShouldBeTrue("non-vacuity: the units really were raised"); @@ -184,7 +184,7 @@ public sealed class NodeManagerLockTimeBenchmarkTests(ITestOutputHelper output) public void QueueEvent(IFilterTarget instance, bool bypassFilter) { } public void Dispose() { } - public INodeManager NodeManager => throw new NotSupportedException(); + public IAsyncNodeManager NodeManager => throw new NotSupportedException(); public ISession Session => throw new NotSupportedException(); public uint Id => 1; public uint SubscriptionId => 1; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerMaxReturnDataValuesSyncTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerMaxReturnDataValuesSyncTests.cs index ea47eedd..f00c4614 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerMaxReturnDataValuesSyncTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerMaxReturnDataValuesSyncTests.cs @@ -39,15 +39,15 @@ public sealed class NodeManagerMaxReturnDataValuesSyncTests(BoundsServerFixture private async Task ReadAdvertisedAsync() { - var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.Read, identity: null); - var nodesToRead = new ReadValueIdCollection + var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.Read, requestLifetime: RequestLifetime.None, identity: null); + var nodesToRead = new List { new ReadValueId { NodeId = VariableIds.HistoryServerCapabilities_MaxReturnDataValues, AttributeId = Attributes.Value }, }; var (values, _) = await fx.Nm.Server.NodeManager.ReadAsync(context, 0, TimestampsToReturn.Neither, nodesToRead, TestContext.Current.CancellationToken); values.Count.ShouldBe(1); StatusCode.IsGood(values[0].StatusCode).ShouldBeTrue(values[0].StatusCode.ToString()); - return (uint)values[0].Value; + return (uint)SdkValueBoundary.ToClr(values[0].WrappedValue)!; } private sealed class CappedSource(int cap) : IHistorianDataSource diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeFilterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeFilterTests.cs index 65107d66..6f147a1c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeFilterTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeFilterTests.cs @@ -42,10 +42,10 @@ public sealed class NodeManagerModelChangeFilterTests : IDisposable sink.EnsureFolder(RawFolder, null, "dev1", AddressSpaceRealm.Raw); var e = nm.BuildNodesAddedModelChange("EqA", AddressSpaceRealm.Uns); - var eqA = e.Changes.Value[0]; - var raw = nm.BuildNodesAddedModelChange(RawFolder, AddressSpaceRealm.Raw).Changes.Value[0]; - e.Changes.Value = [eqA, raw]; - e.Message.Value = new LocalizedText($"Nodes added under {eqA.Affected} and {raw.Affected}"); + var eqA = e.Changes!.Value[0]; + var raw = nm.BuildNodesAddedModelChange(RawFolder, AddressSpaceRealm.Raw).Changes!.Value[0]; + e.Changes!.Value = [eqA, raw]; + e.Message!.Value = new LocalizedText($"Nodes added under {eqA.Affected} and {raw.Affected}"); nm.Authorizer = Authorizer( new NodeAcl @@ -62,9 +62,9 @@ public sealed class NodeManagerModelChangeFilterTests : IDisposable var narrowed = nm.FilterModelChangeFor(Identity(Principal), e).ShouldNotBeNull(); narrowed.ShouldNotBeSameAs(e); - narrowed.Changes.Value.Select(c => c.Affected).ShouldBe([eqA.Affected]); - narrowed.EventId.Value.ShouldBe(e.EventId.Value); - narrowed.Message.Value.Text.ShouldNotContain(RawFolder); + narrowed.Changes!.Value.ToArray()!.Select(c => c.Affected).ShouldBe([eqA.Affected]); + narrowed.EventId!.Value.ToArray().ShouldBe(e.EventId!.Value.ToArray()); + narrowed.Message!.Value.Text!.ShouldNotContain(RawFolder); nm.FilterModelChangeFor(Identity("everything"), e).ShouldBeSameAs(e); nm.FilterModelChangeFor(Identity("nobody"), e).ShouldBeNull(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeOnAddTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeOnAddTests.cs index 53719165..a0bba0af 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeOnAddTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeOnAddTests.cs @@ -51,8 +51,8 @@ public sealed class NodeManagerModelChangeOnAddTests : IDisposable e.ShouldNotBeNull(); e.Changes.ShouldNotBeNull(); - var changes = e.Changes.Value; - changes.Length.ShouldBe(1); + var changes = e.Changes!.Value; + changes.Count.ShouldBe(1); changes[0].Affected.ShouldBe(parent.NodeId); changes[0].AffectedType.ShouldBe(ObjectTypeIds.FolderType); changes[0].Verb.ShouldBe((byte)ModelChangeStructureVerbMask.NodeAdded); @@ -76,8 +76,8 @@ public sealed class NodeManagerModelChangeOnAddTests : IDisposable e.ShouldNotBeNull(); e.Changes.ShouldNotBeNull(); - var changes = e.Changes.Value; - changes.Length.ShouldBe(1); + var changes = e.Changes!.Value; + changes.Count.ShouldBe(1); changes[0].Verb.ShouldBe((byte)ModelChangeStructureVerbMask.NodeAdded); changes[0].AffectedType.ShouldBe(NodeId.Null); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerNativeAlarmRebuildCarryOverTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerNativeAlarmRebuildCarryOverTests.cs index 6a6721ab..fc34aa95 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerNativeAlarmRebuildCarryOverTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerNativeAlarmRebuildCarryOverTests.cs @@ -43,27 +43,27 @@ public sealed class NodeManagerNativeAlarmRebuildCarryOverTests : IDisposable nm.RebuildAddressSpace(); Materialise(nm, native: true); var restored = Condition(nm); - var idBeforeComplete = (byte[]?)restored.EventId.Value?.Clone(); + var idBeforeComplete = (byte[]?)restored.EventId!.Value.ToArray(); // State is back as soon as the node exists — a Read or a ConditionRefresh in between already sees it. - restored.ActiveState.Id.Value.ShouldBeTrue(); - restored.AckedState.Id.Value.ShouldBeFalse(); - restored.Retain.Value.ShouldBeTrue(); - restored.Severity.Value.ShouldBe((ushort)800); - restored.Message.Value.Text.ShouldBe("Xtravel FAULT 1001"); - restored.Quality.Value.Code.ShouldBe(StatusCodes.Good); + restored.ActiveState!.Id!.Value.ShouldBeTrue(); + restored.AckedState!.Id!.Value.ShouldBeFalse(); + restored.Retain!.Value.ShouldBeTrue(); + restored.Severity!.Value.ShouldBe((ushort)800); + restored.Message!.Value.Text.ShouldBe("Xtravel FAULT 1001"); + restored.Quality!.Value.Code.ShouldBe(StatusCodes.Good.Code); nm.CompleteAddressSpaceApply(); // The report happens at the END of the apply (after the notifier wiring), stamped with the instant the // state came about, not the deploy time. - restored.EventId.Value.ShouldNotBe(idBeforeComplete); - restored.Time.Value.ShouldBe(raisedAt); + restored.EventId!.Value.ToArray().ShouldNotBe(idBeforeComplete); + restored.Time!.Value.ShouldBe(raisedAt); // ...and only once: a second completion with nothing pending reports nothing. - var idAfterComplete = (byte[]?)restored.EventId.Value?.Clone(); + var idAfterComplete = (byte[]?)restored.EventId!.Value.ToArray(); nm.CompleteAddressSpaceApply(); - restored.EventId.Value.ShouldBe(idAfterComplete); + restored.EventId!.Value.ToArray().ShouldBe(idAfterComplete); await host.DisposeAsync(); } @@ -87,9 +87,9 @@ public sealed class NodeManagerNativeAlarmRebuildCarryOverTests : IDisposable nm.CompleteAddressSpaceApply(); var restored = Condition(nm); - restored.ActiveState.Id.Value.ShouldBeTrue(); - restored.AckedState.Id.Value.ShouldBeTrue(); - restored.Retain.Value.ShouldBeTrue(); + restored.ActiveState!.Id!.Value.ShouldBeTrue(); + restored.AckedState!.Id!.Value.ShouldBeTrue(); + restored.Retain!.Value.ShouldBeTrue(); await host.DisposeAsync(); } @@ -108,17 +108,17 @@ public sealed class NodeManagerNativeAlarmRebuildCarryOverTests : IDisposable var raisedAt = new DateTime(2026, 10, 8, 9, 0, 0, DateTimeKind.Utc); Materialise(nm, native: true); nm.WriteAlarmCondition(Alarm, Fault(acked: false), raisedAt, AddressSpaceRealm.Raw); - Condition(nm).Comment.Value = new LocalizedText("on it"); + Condition(nm).Comment!.Value = new LocalizedText("on it"); nm.RebuildAddressSpace(); Materialise(nm, native: true); var later = raisedAt.AddMinutes(5); - nm.WriteAlarmQuality(Alarm, StatusCodes.BadNoCommunication, later, AddressSpaceRealm.Raw); // reports at `later` + nm.WriteAlarmQuality(Alarm, StatusCodes.BadNoCommunication.Code, later, AddressSpaceRealm.Raw); // reports at `later` nm.CompleteAddressSpaceApply(); var restored = Condition(nm); - restored.Comment.Value.Text.ShouldBe("on it"); - restored.Time.Value.ShouldBe(later, "the restore report must not stamp the condition back to the raise time"); + restored.Comment!.Value.Text.ShouldBe("on it"); + restored.Time!.Value.ShouldBe(later, "the restore report must not stamp the condition back to the raise time"); await host.DisposeAsync(); } @@ -137,15 +137,15 @@ public sealed class NodeManagerNativeAlarmRebuildCarryOverTests : IDisposable nm.RebuildAddressSpace(); Materialise(nm, native: true); var fresh = Condition(nm); - var idBefore = (byte[]?)fresh.EventId.Value?.Clone(); + var idBefore = (byte[]?)fresh.EventId!.Value.ToArray(); nm.CompleteAddressSpaceApply(); - fresh.EventId.Value.ShouldBe(idBefore); - fresh.ActiveState.Id.Value.ShouldBeFalse(); - fresh.AckedState.Id.Value.ShouldBeTrue(); - fresh.Retain.Value.ShouldBeFalse(); - fresh.Message.Value.Text.ShouldBe("Xtravel", "a normal condition keeps the materialise's message"); - fresh.Severity.Value.ShouldBe((ushort)700, "and its authored severity"); + fresh.EventId!.Value.ToArray().ShouldBe(idBefore); + fresh.ActiveState!.Id!.Value.ShouldBeFalse(); + fresh.AckedState!.Id!.Value.ShouldBeTrue(); + fresh.Retain!.Value.ShouldBeFalse(); + fresh.Message!.Value.Text.ShouldBe("Xtravel", "a normal condition keeps the materialise's message"); + fresh.Severity!.Value.ShouldBe((ushort)700, "and its authored severity"); await host.DisposeAsync(); } @@ -166,11 +166,11 @@ public sealed class NodeManagerNativeAlarmRebuildCarryOverTests : IDisposable nm.RebuildAddressSpace(); Materialise(nm, native: true); var fresh = Condition(nm); - var idBefore = (byte[]?)fresh.EventId.Value?.Clone(); + var idBefore = (byte[]?)fresh.EventId!.Value.ToArray(); nm.CompleteAddressSpaceApply(); - fresh.Quality.Value.Code.ShouldBe(StatusCodes.Good); - fresh.EventId.Value.ShouldBe(idBefore); + fresh.Quality!.Value.Code.ShouldBe(StatusCodes.Good.Code); + fresh.EventId!.Value.ToArray().ShouldBe(idBefore); await host.DisposeAsync(); } @@ -192,8 +192,8 @@ public sealed class NodeManagerNativeAlarmRebuildCarryOverTests : IDisposable nm.CompleteAddressSpaceApply(); var readded = Condition(nm); - readded.ActiveState.Id.Value.ShouldBeFalse(); - readded.AckedState.Id.Value.ShouldBeTrue(); + readded.ActiveState!.Id!.Value.ShouldBeFalse(); + readded.AckedState!.Id!.Value.ShouldBeTrue(); await host.DisposeAsync(); } @@ -212,7 +212,7 @@ public sealed class NodeManagerNativeAlarmRebuildCarryOverTests : IDisposable Materialise(nm, native: false); nm.CompleteAddressSpaceApply(); - Condition(nm).ActiveState.Id.Value.ShouldBeFalse(); + Condition(nm).ActiveState!.Id!.Value.ShouldBeFalse(); await host.DisposeAsync(); } @@ -231,7 +231,7 @@ public sealed class NodeManagerNativeAlarmRebuildCarryOverTests : IDisposable Materialise(nm, native: false); nm.CompleteAddressSpaceApply(); - Condition(nm).ActiveState.Id.Value.ShouldBeFalse(); + Condition(nm).ActiveState!.Id!.Value.ShouldBeFalse(); await host.DisposeAsync(); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStage6EnforcementTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStage6EnforcementTests.cs index 3c6b6605..c56fa972 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStage6EnforcementTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStage6EnforcementTests.cs @@ -49,8 +49,8 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable var (values, errors) = Read(nm, identity: null, nodeId); - errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied); - values[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied); + errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied.Code); + values[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied.Code); } finally { await host.DisposeAsync(); } } @@ -72,8 +72,8 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable var (values, errors) = Read(nm, Identity(), nodeId); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); - values[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); + values[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); } finally { await host.DisposeAsync(); } } @@ -91,8 +91,8 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable var (values, errors) = Read(nm, Identity(), nodeId); - errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied); - values[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied); + errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied.Code); + values[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied.Code); } finally { await host.DisposeAsync(); } } @@ -144,8 +144,8 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable var (_, errors) = Read(nm, Identity(), granted, ungranted); - errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied); - errors[1].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied.Code); + errors[1].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); } finally { await host.DisposeAsync(); } } @@ -190,12 +190,12 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable new UserNameIdentityToken { UserName = "apikey" }, [], Principal, apiKeyId: "k1"); // Prime the cache with an allow. - Read(nm, identity, nodeId).Errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied); + Read(nm, identity, nodeId).Errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied.Code); nm.Authorizer.Decisions.Count.ShouldBeGreaterThan(0, "the allow must actually be cached"); clock.Advance(TimeSpan.FromHours(2)); - Read(nm, identity, nodeId).Errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + Read(nm, identity, nodeId).Errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); } finally { await host.DisposeAsync(); } } @@ -219,12 +219,12 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable var nodeId = Variable(nm, "Plant/Modbus/dev1/Speed"); Read(nm, Identity(), nodeId).Errors[0].StatusCode.Code - .ShouldNotBe(StatusCodes.BadUserAccessDenied); + .ShouldNotBe(StatusCodes.BadUserAccessDenied.Code); // The next deployment carries no grants at all. state.Apply(Cluster, "rev-2", [], [], Scopes()); - Read(nm, Identity(), nodeId).Errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + Read(nm, Identity(), nodeId).Errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); } finally { await host.DisposeAsync(); } } @@ -258,12 +258,12 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable var nodeId = Variable(nm, "Plant/Modbus/dev1/Speed"); var context = new OperationContext( - new RequestHeader(), secureChannelContext: null, RequestType.CreateMonitoredItems, Identity()); + new RequestHeader(), secureChannelContext: null, RequestType.CreateMonitoredItems, requestLifetime: RequestLifetime.None, Identity()); var result = nm.SubscribeToEvents( - context, nm.GetManagerHandle(nodeId), subscriptionId: 1, monitoredItem: null!, unsubscribe: false); + context, nm.GetManagerHandle(nodeId)!, subscriptionId: 1, monitoredItem: null!, unsubscribe: false); - result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code); } finally { await host.DisposeAsync(); } } @@ -324,7 +324,7 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable var sentinel = new ReferenceDescription { NodeId = children[1], // an explicitly DENIED node, from an earlier page - DisplayName = "previous-page", + DisplayName = new LocalizedText("previous-page"), }; var references = Browse(nm, Identity(), deviceId, sentinel); @@ -442,7 +442,7 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable nm.Authorizer = TagBrowseAuthorizer($"{device}/T2", $"{device}/T4", $"{device}/T6"); var context = new OperationContext( - new RequestHeader(), secureChannelContext: null, RequestType.Browse, Identity()); + new RequestHeader(), secureChannelContext: null, RequestType.Browse, requestLifetime: RequestLifetime.None, Identity()); // A relative path that resolves to exactly one REAL match (T1) under the SDK's own path // resolution — the trigger for the single Add() call FanOutTargetIdList intercepts below. @@ -461,7 +461,7 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable var unresolvedTargetIds = new List(); nm.TranslateBrowsePath( - context, nm.GetManagerHandle(deviceId), relativePath, targetIds, unresolvedTargetIds); + context, nm.GetManagerHandle(deviceId)!, relativePath, targetIds, unresolvedTargetIds); var granted = new[] { children[1], children[3], children[5] }; var expected = new List { sentinel } @@ -596,7 +596,7 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable // Non-vacuity: this really is a STRING identifier of length zero, the one case the guard used // to let through — not a null identifier the guard already rejected. - emptyId.Identifier.ShouldBeOfType().ShouldBeEmpty(); + emptyId.IdentifierAsString.ShouldBeEmpty(); var identity = Identity(); var items = Enumerable.Range(0, 2) @@ -775,7 +775,7 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable /// Registers a stub data-change item against as if the SDK had created it. private static StubSampledItem TrackItem(OtOpcUaNodeManager nm, NodeId nodeId, IUserIdentity identity) => - TrackItem(nm, (NodeHandle)nm.GetManagerHandle(nodeId), identity); + TrackItem(nm, (NodeHandle)nm.GetManagerHandle(nodeId)!, identity); /// /// Registers a stub data-change item against a hand-built — the only way @@ -804,7 +804,7 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable OtOpcUaNodeManager nm, IUserIdentity? identity, params (NodeId Node, uint Attribute)[] reads) { var context = new OperationContext( - new RequestHeader(), secureChannelContext: null, RequestType.Read, identity); + new RequestHeader(), secureChannelContext: null, RequestType.Read, requestLifetime: RequestLifetime.None, identity); var nodesToRead = reads .Select(r => new ReadValueId { NodeId = r.Node, AttributeId = r.Attribute }) @@ -827,13 +827,13 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable params ReferenceDescription[] previousPage) { var context = new OperationContext( - new RequestHeader(), secureChannelContext: null, RequestType.Browse, identity); + new RequestHeader(), secureChannelContext: null, RequestType.Browse, requestLifetime: RequestLifetime.None, identity); var continuationPoint = new ContinuationPoint { - NodeToBrowse = nm.GetManagerHandle(source), + NodeToBrowse = nm.GetManagerHandle(source)!, BrowseDirection = BrowseDirection.Forward, - ReferenceTypeId = null, + ReferenceTypeId = default, IncludeSubtypes = true, NodeClassMask = 0, ResultMask = BrowseResultMask.All, @@ -982,17 +982,17 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable public NodeId NodeId => handle.NodeId; - public void QueueValue(DataValue value, ServiceResult error, bool ignoreFilters) + public void QueueValue(in DataValue value, ServiceResult? error, bool ignoreFilters) { if (error?.StatusCode.Code == StatusCodes.BadUserAccessDenied) Denials++; } - public void QueueValue(DataValue value, ServiceResult error) => QueueValue(value, error, false); + public void QueueValue(in DataValue value, ServiceResult? error) => QueueValue(value, error, false); public void Dispose() { } // ── everything below is untouched by revalidation ── - public INodeManager NodeManager => throw new NotSupportedException(); + public IAsyncNodeManager NodeManager => throw new NotSupportedException(); public ISession Session => throw new NotSupportedException(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStaleMonitoredNodeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStaleMonitoredNodeTests.cs index 5ddd6b71..35f2f90c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStaleMonitoredNodeTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStaleMonitoredNodeTests.cs @@ -109,7 +109,7 @@ public sealed class NodeManagerStaleMonitoredNodeTests : IDisposable nm.EnsureFolder("eq-1", null, "Equipment", AddressSpaceRealm.Uns); nm.MaterialiseAlarmCondition("al-1", "eq-1", "High", "OffNormalAlarm", 500, AddressSpaceRealm.Uns, isNative: false); var nodeId = new NodeId("al-1", nm.TryGetFolder("eq-1")!.NodeId.NamespaceIndex); - var handle = (NodeHandle)nm.GetManagerHandle(nodeId); + var handle = (NodeHandle)nm.GetManagerHandle(nodeId)!; handle.ShouldNotBeNull("non-vacuity: the condition is materialised"); var item = new StubItem(handle, Identity()); nm.TrackLiveMonitoredItem(handle, item); @@ -121,7 +121,7 @@ public sealed class NodeManagerStaleMonitoredNodeTests : IDisposable item.Statuses.ShouldContain((StatusCode)StatusCodes.BadNodeIdUnknown, "the dropped instance's item is told"); nm.LiveItemCount.ShouldBe(0); - ((NodeHandle)nm.GetManagerHandle(nodeId)).ShouldNotBeNull("positive control: the swapped-in condition exists"); + ((NodeHandle)nm.GetManagerHandle(nodeId)!).ShouldNotBeNull("positive control: the swapped-in condition exists"); } finally { await host.DisposeAsync(); } } @@ -131,7 +131,7 @@ public sealed class NodeManagerStaleMonitoredNodeTests : IDisposable { nm.EnsureVariable(rawPath, null, rawPath, "Float", writable: false, realm: AddressSpaceRealm.Raw); var nodeId = nm.TryGetVariable(rawPath, AddressSpaceRealm.Raw)!.NodeId; - var handle = (NodeHandle)nm.GetManagerHandle(nodeId); + var handle = (NodeHandle)nm.GetManagerHandle(nodeId)!; var item = new StubItem(handle, Identity()); nm.TrackLiveMonitoredItem(handle, item); @@ -188,11 +188,11 @@ public sealed class NodeManagerStaleMonitoredNodeTests : IDisposable public IUserIdentity EffectiveIdentity => identity; public object ManagerHandle => handle; public NodeId NodeId => handle.NodeId; - public void QueueValue(DataValue value, ServiceResult error, bool ignoreFilters) => Statuses.Add(error?.StatusCode ?? value.StatusCode); - public void QueueValue(DataValue value, ServiceResult error) => QueueValue(value, error, false); + public void QueueValue(in DataValue value, ServiceResult? error, bool ignoreFilters) => Statuses.Add(error?.StatusCode ?? value.StatusCode); + public void QueueValue(in DataValue value, ServiceResult? error) => QueueValue(value, error, false); public void Dispose() { } - public INodeManager NodeManager => throw new NotSupportedException(); + public IAsyncNodeManager NodeManager => throw new NotSupportedException(); public ISession Session => throw new NotSupportedException(); public uint Id => throw new NotSupportedException(); public uint SubscriptionId => throw new NotSupportedException(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalRemoveTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalRemoveTests.cs index d3776e94..06054510 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalRemoveTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalRemoveTests.cs @@ -76,8 +76,8 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable e.ShouldNotBeNull(); e.Changes.ShouldNotBeNull(); - var changes = e.Changes.Value; - changes.Length.ShouldBe(1); + var changes = e.Changes!.Value; + changes.Count.ShouldBe(1); changes[0].Verb.ShouldBe((byte)ModelChangeStructureVerbMask.NodeDeleted); await host.DisposeAsync(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalShapeUpdateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalShapeUpdateTests.cs index 744600d7..31e026e7 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalShapeUpdateTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalShapeUpdateTests.cs @@ -55,7 +55,7 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable applied.ShouldBeTrue(); node.DataType.ShouldBe(DataTypeIds.Int32); // swapped in place node.ValueRank.ShouldBe(ValueRanks.Scalar); - node.Value.ShouldBeNull(); // stale Float value dropped + node.Value.IsNull.ShouldBeTrue(); // stale Float value dropped node.StatusCode.ShouldBe((StatusCode)StatusCodes.BadWaitingForInitialData); await host.DisposeAsync(); @@ -79,9 +79,9 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable applied.ShouldBeTrue(); node.ValueRank.ShouldBe(ValueRanks.OneDimension); - node.ArrayDimensions.ShouldNotBeNull(); + node.ArrayDimensions.IsNull.ShouldBeFalse(); node.ArrayDimensions[0].ShouldBe(8u); - node.Value.ShouldBeNull(); // stale scalar value dropped + node.Value.IsNull.ShouldBeTrue(); // stale scalar value dropped node.StatusCode.ShouldBe((StatusCode)StatusCodes.BadWaitingForInitialData); await host.DisposeAsync(); @@ -106,7 +106,7 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable applied.ShouldBeTrue(); node.ArrayDimensions[0].ShouldBe(8u); - node.Value.ShouldBeNull(); + node.Value.IsNull.ShouldBeTrue(); node.StatusCode.ShouldBe((StatusCode)StatusCodes.BadWaitingForInitialData); await host.DisposeAsync(); @@ -131,8 +131,8 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable applied.ShouldBeTrue(); node.ValueRank.ShouldBe(ValueRanks.Scalar); - node.ArrayDimensions.ShouldBeNull(); - node.Value.ShouldBeNull(); + node.ArrayDimensions.IsNull.ShouldBeTrue(); + node.Value.IsNull.ShouldBeTrue(); node.StatusCode.ShouldBe((StatusCode)StatusCodes.BadWaitingForInitialData); await host.DisposeAsync(); @@ -154,7 +154,7 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable applied.ShouldBeTrue(); node.ValueRank.ShouldBe(ValueRanks.OneDimension); - node.ArrayDimensions.ShouldNotBeNull(); + node.ArrayDimensions.IsNull.ShouldBeFalse(); node.ArrayDimensions[0].ShouldBe(0u); // null length ⇒ [0], same as EnsureVariable await host.DisposeAsync(); @@ -222,8 +222,8 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable e.ShouldNotBeNull(); e.Changes.ShouldNotBeNull(); - var changes = e.Changes.Value; - changes.Length.ShouldBe(1); + var changes = e.Changes!.Value; + changes.Count.ShouldBe(1); changes[0].Affected.ShouldBe(node.NodeId); changes[0].AffectedType.ShouldBe(VariableTypeIds.BaseDataVariableType); changes[0].Verb.ShouldBe((byte)ModelChangeStructureVerbMask.DataTypeChanged); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteRevertTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteRevertTests.cs index 2a44021b..5386b342 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteRevertTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteRevertTests.cs @@ -219,13 +219,13 @@ public sealed class NodeManagerWriteRevertTests : IDisposable 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 + 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(); } @@ -247,7 +247,7 @@ public sealed class NodeManagerWriteRevertTests : IDisposable outcome: new NodeWriteOutcome(false, null), optimisticValue: 42, priorValue: 7, clientUserId: "op"); - audit.Message.Value.Text.ShouldContain("device write rejected"); + audit.Message!.Value.Text!.ShouldContain("device write rejected"); await host.DisposeAsync(); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostImpersonationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostImpersonationTests.cs index 5a9275c2..4a76a34a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostImpersonationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostImpersonationTests.cs @@ -22,7 +22,7 @@ public sealed class OpcUaApplicationHostImpersonationTests [Fact] public void HandleImpersonation_username_success_sets_identity_and_no_validation_error() { - var token = new UserNameIdentityToken { UserName = "alice", DecryptedPassword = Encoding.UTF8.GetBytes("secret") }; + var token = new UserNameIdentityTokenHandler(new UserNameIdentityToken { UserName = "alice" }) { DecryptedPassword = Encoding.UTF8.GetBytes("secret") }; var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription()); var authenticator = new RecordingAuthenticator( OpcUaUserAuthResult.Allow("Alice", new[] { "ReadOnly", "WriteOperate" })); @@ -43,7 +43,7 @@ public sealed class OpcUaApplicationHostImpersonationTests public void HandleImpersonation_username_success_carries_roles_on_identity() { var roles = new[] { "ReadOnly", "WriteOperate", "AlarmAck" }; - var token = new UserNameIdentityToken { UserName = "alice", DecryptedPassword = Encoding.UTF8.GetBytes("secret") }; + var token = new UserNameIdentityTokenHandler(new UserNameIdentityToken { UserName = "alice" }) { DecryptedPassword = Encoding.UTF8.GetBytes("secret") }; var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription()); var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Allow("Alice", roles)); @@ -57,15 +57,15 @@ public sealed class OpcUaApplicationHostImpersonationTests [Fact] public void HandleImpersonation_username_denial_sets_validation_error_and_no_identity() { - var token = new UserNameIdentityToken { UserName = "mallory", DecryptedPassword = Encoding.UTF8.GetBytes("wrong") }; + var token = new UserNameIdentityTokenHandler(new UserNameIdentityToken { UserName = "mallory" }) { DecryptedPassword = Encoding.UTF8.GetBytes("wrong") }; var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription()); var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Deny("Invalid credentials")); OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger.Instance); args.Identity.ShouldBeNull(); - args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected); - args.IdentityValidationError.LocalizedText.Text.ShouldContain("Invalid credentials"); + args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected.Code); + args.IdentityValidationError.LocalizedText.Text!.ShouldContain("Invalid credentials"); } /// @@ -81,7 +81,7 @@ public sealed class OpcUaApplicationHostImpersonationTests [Fact] public void HandleImpersonation_anonymous_token_is_admitted_with_a_role_carrying_identity() { - var args = new ImpersonateEventArgs(new AnonymousIdentityToken(), AnonPolicy, new EndpointDescription()); + var args = new ImpersonateEventArgs(new AnonymousIdentityTokenHandler(), AnonPolicy, new EndpointDescription()); var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Allow("x", Array.Empty())); OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger.Instance); @@ -98,35 +98,35 @@ public sealed class OpcUaApplicationHostImpersonationTests [Fact] public void HandleImpersonation_anonymous_token_is_rejected_when_disallowed() { - var args = new ImpersonateEventArgs(new AnonymousIdentityToken(), AnonPolicy, new EndpointDescription()); + var args = new ImpersonateEventArgs(new AnonymousIdentityTokenHandler(), AnonPolicy, new EndpointDescription()); var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Allow("x", Array.Empty())); OpcUaApplicationHost.HandleImpersonation( authenticator, args, NullLogger.Instance, allowAnonymous: false); args.Identity.ShouldBeNull(); - args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected); + args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected.Code); } /// Verifies authenticator exceptions result in rejection with validation error. [Fact] public void HandleImpersonation_authenticator_throwing_results_in_rejection() { - var token = new UserNameIdentityToken { UserName = "bob", DecryptedPassword = Encoding.UTF8.GetBytes("x") }; + var token = new UserNameIdentityTokenHandler(new UserNameIdentityToken { UserName = "bob" }) { DecryptedPassword = Encoding.UTF8.GetBytes("x") }; var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription()); var authenticator = new ThrowingAuthenticator(new InvalidOperationException("LDAP unreachable")); OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger.Instance); args.Identity.ShouldBeNull(); - args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected); + args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected.Code); } /// Verifies null username is normalized to empty string before authenticator call. [Fact] public void HandleImpersonation_null_username_treated_as_empty_string() { - var token = new UserNameIdentityToken { UserName = null, DecryptedPassword = Encoding.UTF8.GetBytes("abc") }; + var token = new UserNameIdentityTokenHandler(new UserNameIdentityToken { UserName = null }) { DecryptedPassword = Encoding.UTF8.GetBytes("abc") }; var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription()); var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Deny("no user")); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostSecurityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostSecurityTests.cs index f8005f8b..55be1cb0 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostSecurityTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostSecurityTests.cs @@ -127,15 +127,15 @@ public sealed class OpcUaApplicationHostSecurityTests : IDisposable AutoAcceptUntrustedClientCertificates = true, }, NullLogger.Instance), - () => new StandardServer(), Ct); + () => new OtOpcUaSdkServer(), Ct); await using var host = started.Host; - var config = host.ApplicationInstance!.ApplicationConfiguration; - config.ServerConfiguration.SecurityPolicies.Count.ShouldBe(3); - config.ServerConfiguration.UserTokenPolicies.Count.ShouldBe(2); - config.SecurityConfiguration.AutoAcceptUntrustedCertificates.ShouldBeTrue(); + var config = host.ApplicationInstance!.ApplicationConfiguration!; + config.ServerConfiguration!.SecurityPolicies.Count.ShouldBe(3); + config.ServerConfiguration!.UserTokenPolicies.Count.ShouldBe(2); + config.SecurityConfiguration!.AutoAcceptUntrustedCertificates.ShouldBeTrue(); - var modes = config.ServerConfiguration.SecurityPolicies + var modes = config.ServerConfiguration!.SecurityPolicies.ToArray()! .Select(p => p.SecurityMode) .OrderBy(m => (int)m) .ToArray(); @@ -162,10 +162,10 @@ public sealed class OpcUaApplicationHostSecurityTests : IDisposable AutoAcceptUntrustedClientCertificates = false, }, NullLogger.Instance), - () => new StandardServer(), Ct); + () => new OtOpcUaSdkServer(), Ct); await using var host = started.Host; - var policies = host.ApplicationInstance!.ApplicationConfiguration.ServerConfiguration.SecurityPolicies; + var policies = host.ApplicationInstance!.ApplicationConfiguration!.ServerConfiguration!.SecurityPolicies; policies.Count.ShouldBe(1); policies[0].SecurityMode.ShouldBe(MessageSecurityMode.SignAndEncrypt); policies[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostServerArrayTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostServerArrayTests.cs index aec153e4..a8064749 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostServerArrayTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostServerArrayTests.cs @@ -38,11 +38,11 @@ public sealed class OpcUaApplicationHostServerArrayTests PeerApplicationUris = new[] { "urn:OtOpcUa.UnitTest.NodeB" }, }, NullLogger.Instance), - () => new StandardServer(), CancellationToken.None); + () => new OtOpcUaSdkServer(), CancellationToken.None); await using var host = started.Host; var server = started.Server; - var serverArray = server.CurrentInstance.ServerObject.ServerArray.Value; + var serverArray = server.CurrentInstance.ServerObject.ServerArray!.Value.ToArray(); serverArray.ShouldNotBeNull(); serverArray.ShouldContain("urn:OtOpcUa.UnitTest.NodeA"); serverArray.ShouldContain("urn:OtOpcUa.UnitTest.NodeB"); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostTests.cs index 3fb71d61..6e1f5f4b 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostTests.cs @@ -1,3 +1,4 @@ +using Opc.Ua; using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua.Server; using Shouldly; @@ -34,7 +35,7 @@ public sealed class OpcUaApplicationHostTests : IDisposable PkiStoreRoot = _pkiRoot, }, NullLogger.Instance), - () => new StandardServer(), Ct); + () => new OtOpcUaSdkServer(), Ct); await using var host = started.Host; var ownCerts = Path.Combine(_pkiRoot, "own", "certs"); @@ -57,7 +58,7 @@ public sealed class OpcUaApplicationHostTests : IDisposable PkiStoreRoot = _pkiRoot, }, NullLogger.Instance), - () => new StandardServer(), Ct); + () => new OtOpcUaSdkServer(), Ct); await first.Host.DisposeAsync(); var ownCerts = Path.Combine(_pkiRoot, "own", "certs"); @@ -75,7 +76,7 @@ public sealed class OpcUaApplicationHostTests : IDisposable PkiStoreRoot = _pkiRoot, }, NullLogger.Instance), - () => new StandardServer(), Ct); + () => new OtOpcUaSdkServer(), Ct); await second.Host.DisposeAsync(); var secondFiles = Directory.GetFiles(ownCerts).OrderBy(f => f).ToArray(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ReverseWalkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ReverseWalkTests.cs index 12bf4b1a..de2b4697 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ReverseWalkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ReverseWalkTests.cs @@ -17,7 +17,7 @@ public sealed class ReverseWalkTests private static readonly DateTime T0 = new(2026, 10, 12, 18, 0, 0, DateTimeKind.Utc); private static DataValueSnapshot At(long ticks, double v) => - new(v, StatusCodes.Good, new DateTime(ticks, DateTimeKind.Utc), new DateTime(ticks, DateTimeKind.Utc)); + new(v, StatusCodes.Good.Code, new DateTime(ticks, DateTimeKind.Utc), new DateTime(ticks, DateTimeKind.Utc)); /// /// A randomized walk over AVEVA-shaped forward reads (inclusive end, a fabricated row at the query start — optionally @@ -304,7 +304,7 @@ public sealed class ReverseWalkTests var rows = new List { prior is null - ? new DataValueSnapshot(-1d, StatusCodes.BadNoData, stamp, stamp) + ? new DataValueSnapshot(-1d, StatusCodes.BadNoData.Code, stamp, stamp) : new DataValueSnapshot(-2d, prior.StatusCode, stamp, stamp), }; rows.AddRange(series.Where(s => s.SourceTimestampUtc >= startUtc && s.SourceTimestampUtc <= endUtc)); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/RoleCarryingUserIdentityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/RoleCarryingUserIdentityTests.cs new file mode 100644 index 00000000..baa61302 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/RoleCarryingUserIdentityTests.cs @@ -0,0 +1,38 @@ +using Opc.Ua; +using Opc.Ua.Server; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +public sealed class RoleCarryingUserIdentityTests +{ + [Fact] + public void Mandatory_and_dynamic_SDK_roles_preserve_the_application_identity() + { + var namespaces = new NamespaceTable(); + var identity = new RoleCarryingUserIdentity(new UserNameIdentityToken { UserName = "apikey" }, + [OpcUaDataPlaneRoles.WriteOperate, OpcUaDataPlaneRoles.AlarmAck], "machine-a", "key-a"); + var trusted = identity.WithAdditionalRoles([Role.TrustedApplication], namespaces); + var mapped = trusted.WithAdditionalRoles([Role.AuthenticatedUser], namespaces); + var actual = mapped.ShouldBeOfType(); + actual.Principal.ShouldBe("machine-a"); + actual.ApiKeyId.ShouldBe("key-a"); + actual.Roles.ShouldBe([OpcUaDataPlaneRoles.WriteOperate, OpcUaDataPlaneRoles.AlarmAck]); + actual.GrantedRoleIds.Contains(ExpandedNodeId.ToNodeId(Role.TrustedApplication.RoleId, namespaces)).ShouldBeTrue(); + actual.GrantedRoleIds.Contains(ExpandedNodeId.ToNodeId(Role.AuthenticatedUser.RoleId, namespaces)).ShouldBeTrue(); + } + + [Fact] + public void SDK_restriction_discards_application_authority_and_cannot_restore_it_by_augmentation() + { + var namespaces = new NamespaceTable(); + var identity = new RoleCarryingUserIdentity(new UserNameIdentityToken { UserName = "apikey" }, + [OpcUaDataPlaneRoles.WriteOperate], "machine-a", "key-a"); + var restricted = RoleBasedIdentity.CreateRestricted(identity, [Role.Anonymous], namespaces); + (restricted is RoleCarryingUserIdentity).ShouldBeFalse(); + (restricted.WithAdditionalRoles([Role.TrustedApplication], namespaces) is RoleCarryingUserIdentity).ShouldBeFalse(); + restricted.GrantedRoleIds.Count.ShouldBe(1); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Sdk2ActivationPeerTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Sdk2ActivationPeerTests.cs new file mode 100644 index 00000000..59d32c95 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Sdk2ActivationPeerTests.cs @@ -0,0 +1,45 @@ +using System.Net; +using Opc.Ua; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +public sealed class Sdk2ActivationPeerTests +{ + [Fact] + public async Task Concurrent_activations_keep_their_own_peer_across_awaits() + { + using var server = new OtOpcUaSdkServer(); + var peers = new IPAddress?[] { IPAddress.Parse("127.0.0.1"), IPAddress.Parse("::1"), IPAddress.Parse("::ffff:10.1.2.3"), null }; + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var count = 0; + await Task.WhenAll(peers.Select(async peer => await server.WithActivationPeerAddressAsync(peer, async () => + { + server.ActivationPeerAddress.ShouldBe(peer); + if (Interlocked.Increment(ref count) == peers.Length) entered.SetResult(); + await entered.Task; + server.ActivationPeerAddress.ShouldBe(peer); + return new ActivateSessionResponse(); + }))); + server.ActivationPeerAddress.ShouldBeNull(); + } + + [Fact] + public async Task Failure_and_cancellation_restore_the_outer_peer_including_unknown() + { + using var server = new OtOpcUaSdkServer(); + var outer = IPAddress.Parse("10.2.3.4"); + await server.WithActivationPeerAddressAsync(outer, async () => + { + await Should.ThrowAsync(async () => + await server.WithActivationPeerAddressAsync(null, () => throw new InvalidOperationException("failed activation"))); + server.ActivationPeerAddress.ShouldBe(outer); + await Should.ThrowAsync(async () => + await server.WithActivationPeerAddressAsync(IPAddress.IPv6Loopback, () => throw new OperationCanceledException())); + server.ActivationPeerAddress.ShouldBe(outer); + return new ActivateSessionResponse(); + }); + server.ActivationPeerAddress.ShouldBeNull(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Sdk2CertificateLifecycleTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Sdk2CertificateLifecycleTests.cs new file mode 100644 index 00000000..2fc3e3ae --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Sdk2CertificateLifecycleTests.cs @@ -0,0 +1,64 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Identity; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +public sealed class Sdk2CertificateLifecycleTests +{ + [Fact] + public async Task Restart_reuses_the_application_certificate_and_its_private_key() + { + var pki = Path.Combine(Path.GetTempPath(), $"otua-sdk2-cert-{Guid.NewGuid():N}"); + try + { + string? thumbprint = null; + for (var restart = 0; restart < 2; restart++) + { + var started = await OpcUaHostStart.StartAsync(port => new OpcUaApplicationHost(new OpcUaApplicationHostOptions + { + ApplicationName = "SDK2.CertReuse", ApplicationUri = "urn:SDK2.CertReuse", + PublicHostname = "localhost", OpcUaPort = port, PkiStoreRoot = pki, AllowAnonymous = false, + }, NullLogger.Instance), () => new OtOpcUaSdkServer(), TestContext.Current.CancellationToken); + await using var host = started.Host; + var keyFile = Directory.GetFiles(Path.Combine(pki, "own"), "*.pfx", SearchOption.AllDirectories).ShouldHaveSingleItem(); + using var certificate = X509CertificateLoader.LoadPkcs12FromFile(keyFile, null); + certificate.HasPrivateKey.ShouldBeTrue(); + if (thumbprint is null) thumbprint = certificate.Thumbprint; + else certificate.Thumbprint.ShouldBe(thumbprint); + using var key = certificate.GetRSAPrivateKey(); + using var publicKey = certificate.GetRSAPublicKey(); + var signature = key!.SignData([1, 2, 3], HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + publicKey!.VerifyData(new byte[] { 1, 2, 3 }, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1).ShouldBeTrue(); + var registry = started.Server.CurrentInstance.IdentityRegistry; + var context = new AuthenticationContext(new AnonymousIdentityTokenHandler(), + new UserTokenPolicy(UserTokenType.Anonymous), new EndpointDescription(), started.Server.MessageContext); + (await registry.AuthenticateAsync(context, TestContext.Current.CancellationToken)).Outcome.ShouldBe(AuthenticationOutcome.Rejected); + var userContext = context with + { + TokenHandler = new UserNameIdentityTokenHandler("apikey", "invalid"u8), + UserTokenPolicy = new UserTokenPolicy(UserTokenType.UserName), + }; + (await registry.AuthenticateAsync(userContext, TestContext.Current.CancellationToken)).Outcome.ShouldBe(AuthenticationOutcome.Rejected); + } + } + finally { if (Directory.Exists(pki)) Directory.Delete(pki, true); } + } + + [Fact] + public async Task A_canceled_start_disposes_its_application_owner() + { + var pki = Path.Combine(Path.GetTempPath(), $"otua-sdk2-canceled-{Guid.NewGuid():N}"); + await using var host = new OpcUaApplicationHost(new OpcUaApplicationHostOptions { PkiStoreRoot = pki }, NullLogger.Instance); + using var server = new OtOpcUaSdkServer(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + await Should.ThrowAsync(() => host.StartAsync(server, cancellation.Token)); + host.ApplicationInstance.ShouldBeNull(); + if (Directory.Exists(pki)) Directory.Delete(pki, true); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Sdk2EquipmentVariableTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Sdk2EquipmentVariableTests.cs new file mode 100644 index 00000000..1919d29b --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Sdk2EquipmentVariableTests.cs @@ -0,0 +1,243 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Server; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +public sealed class Sdk2EquipmentVariableTests : IAsyncLifetime +{ + private readonly string _pki = Path.Combine(Path.GetTempPath(), $"otua-sdk2-values-{Guid.NewGuid():N}"); + private OpcUaApplicationHost _host = null!; + private OtOpcUaSdkServer _server = null!; + private OtOpcUaNodeManager _manager = null!; + private readonly RecordingGateway _gateway = new(); + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + public async ValueTask InitializeAsync() + { + var started = await OpcUaHostStart.StartAsync(port => new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = "SDK2.ValueContracts", ApplicationUri = "urn:SDK2.ValueContracts", + PublicHostname = "localhost", OpcUaPort = port, PkiStoreRoot = _pki, + }, NullLogger.Instance), () => new OtOpcUaSdkServer(), Ct); + _host = started.Host; + _server = started.Server; + _manager = _server.NodeManager!; + _manager.NodeWriteGateway = _gateway; + } + + public async ValueTask DisposeAsync() + { + await _host.DisposeAsync(); + if (Directory.Exists(_pki)) Directory.Delete(_pki, true); + } + + [Fact] + public void Double_to_Float_retains_the_scalar_driver_contract() + { + var node = Node("Float"); + Write(node, 3.25d).ShouldBe(ServiceResult.Good); + _gateway.Values.ShouldHaveSingleItem().ShouldBeOfType().ShouldBe(3.25d); + SdkValueBoundary.ToClr(node.Value).ShouldBeOfType(); + } + + [Fact] + public async Task Checked_arrays_are_converted_before_the_async_node_write_dispatches() + { + var node = Node("Int16", array: true); + var result = await node.WriteAttributeAsync(Context(), Attributes.Value, default, + new DataValue(SdkValueBoundary.FromClr(new[] { 1, 2 })), Ct); + result.ShouldBe(ServiceResult.Good); + _gateway.Values.ShouldHaveSingleItem().ShouldBeOfType().ShouldBe([1, 2]); + SdkValueBoundary.ToClr(node.Value).ShouldBeOfType().ShouldBe([1, 2]); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Scalar_shaped_arrays_follow_the_existing_driver_capability(bool refuse) + { + var node = Node("Int16", refuse: refuse); + var result = Write(node, new short[] { 1, 2 }); + result.StatusCode.ShouldBe(refuse ? StatusCodes.BadTypeMismatch : StatusCodes.Good); + _gateway.Values.Count.ShouldBe(refuse ? 0 : 1); + } + + [Theory] + [InlineData("overflow")] + [InlineData("fraction")] + [InlineData("null")] + [InlineData("matrix")] + [InlineData("length")] + [InlineData("range")] + public void Invalid_writes_never_dispatch_or_mutate_the_tuple(string fault) + { + var node = Node("Int16", array: true); + _manager.WriteValue("v", new short[] { 7, 8 }, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + var oldValue = node.Value; + var oldStatus = node.StatusCode; + var oldTime = node.Timestamp; + var payload = fault switch + { + "overflow" => Variant.From((ArrayOf)[1, 32768]), + "fraction" => Variant.From((ArrayOf)[1d, 2.5d]), + "null" => Variant.Null, + "matrix" => Variant.From((MatrixOf)new short[,] { { 1, 2 }, { 3, 4 } }), + "length" => Variant.From((ArrayOf)[1]), + _ => Variant.From((ArrayOf)[1, 2]), + }; + var result = node.WriteAttribute(Context(), Attributes.Value, + fault == "range" ? new NumericRange(0, 1) : default, new DataValue(payload)); + result.StatusCode.ShouldBe(fault == "range" ? StatusCodes.BadWriteNotSupported : StatusCodes.BadTypeMismatch); + _gateway.Values.ShouldBeEmpty(); + node.Value.ShouldBe(oldValue); + node.StatusCode.ShouldBe(oldStatus); + node.Timestamp.ShouldBe(oldTime); + } + + [Theory] + [InlineData(AddressSpaceRealm.Raw)] + [InlineData(AddressSpaceRealm.Uns)] + public void A_denied_identity_with_bad_shape_stays_denied(AddressSpaceRealm realm) + { + var node = Node("Int16", array: true, realm: realm); + Write(node, null, authorized: false).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); + _gateway.Values.ShouldBeEmpty(); + } + + [Fact] + public void Readonly_to_writable_to_readonly_uses_the_same_adapter() + { + var node = Node("Float", writable: false); + Write(node, 1d).StatusCode.ShouldBe(StatusCodes.BadNotWritable); + _manager.UpdateTagAttributes("v", true, null, "Float", false, null, AddressSpaceRealm.Raw); + Write(node, 2d).ShouldBe(ServiceResult.Good); + _manager.UpdateTagAttributes("v", false, null, "Float", false, null, AddressSpaceRealm.Raw); + Write(node, 3d).StatusCode.ShouldBe(StatusCodes.BadNotWritable); + _gateway.Values.ShouldHaveSingleItem().ShouldBe(2d); + } + + [Fact] + public void SDK_metadata_access_checks_stay_in_force() + { + var node = Node("Int32"); + var infoBits = new StatusCode(0x00004000u); + node.WriteAttribute(Context(), Attributes.Value, default, new DataValue(Variant.From(1), infoBits)) + .ShouldBe(ServiceResult.Good); + foreach (var status in new[] { StatusCodes.GoodClamped, StatusCodes.BadNoData }) + node.WriteAttribute(Context(), Attributes.Value, default, new DataValue(Variant.From(2), status)) + .StatusCode.ShouldBe(StatusCodes.BadWriteNotSupported); + node.WriteAttribute(Context(), Attributes.Value, default, + new DataValue(Variant.From(3), StatusCodes.Good, DateTime.UtcNow)).StatusCode.ShouldBe(StatusCodes.BadWriteNotSupported); + node.WriteAttribute(Context(), Attributes.Value, default, + new DataValue(Variant.From(3), StatusCodes.Good, default, DateTime.UtcNow)).StatusCode.ShouldBe(StatusCodes.BadWriteNotSupported); + node.AccessLevel |= AccessLevels.StatusWrite | AccessLevels.TimestampWrite; + node.UserAccessLevel = AccessLevels.CurrentRead | AccessLevels.CurrentWrite; + node.WriteAttribute(Context(), Attributes.Value, default, new DataValue(Variant.From(4), StatusCodes.BadNoData)) + .StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); + node.WriteAttribute(Context(), Attributes.Value, default, + new DataValue(Variant.From(4), StatusCodes.Good, DateTime.UtcNow)).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); + node.OnReadUserAccessLevel = (ISystemContext _, NodeState _, ref byte access) => + { + access = AccessLevels.CurrentRead; + return ServiceResult.Good; + }; + Write(node, 5).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); + _gateway.Values.ShouldHaveSingleItem(); + } + + [Fact] + public async Task A_direct_async_sampler_waits_for_the_complete_published_tuple() + { + var node = Node("Int32"); + var stamp = new DateTime(2026, 11, 10, 10, 0, 0, DateTimeKind.Utc); + using var started = new ManualResetEventSlim(); + Task<(ServiceResult Result, DataValue Value)> read; + lock (_manager.Lock) + { + node.Value = Variant.From(42); + read = Task.Run(async () => + { + started.Set(); + return await node.ReadAttributeAsync(Context(), Attributes.Value, default, default, default, Ct); + }, Ct); + started.Wait(Ct); + read.IsCompleted.ShouldBeFalse(); + node.StatusCode = StatusCodes.UncertainLastUsableValue; + node.Timestamp = stamp; + } + var result = await read; + SdkValueBoundary.ToClr(result.Value.WrappedValue).ShouldBe(42); + result.Value.StatusCode.ShouldBe(StatusCodes.UncertainLastUsableValue); + result.Value.SourceTimestamp.ToDateTime().ShouldBe(stamp); + } + + [Fact] + public void Published_values_retain_existing_CLR_types_and_null_semantics() + { + object?[] cases = [true, (sbyte)-1, (byte)1, (short)-2, (ushort)2, -3, 3u, -4L, 4UL, + 1.25f, 2.5d, "", "text", null, Guid.NewGuid(), DateTime.UtcNow, + new byte[] { 1, 2 }, Array.Empty(), new int[] { 1, 2 }, Array.Empty(), new string[] { "a", "" }]; + for (var i = 0; i < cases.Length; i++) + { + var id = $"payload/{i}"; + _manager.EnsureVariable(id, null, id, "BaseDataType", false, AddressSpaceRealm.Raw); + _manager.WriteValue(id, cases[i], OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + var actual = SdkValueBoundary.ToClr(_manager.TryGetVariable(id, AddressSpaceRealm.Raw)!.Value); + if (cases[i] is { } expected) actual!.GetType().ShouldBe(expected.GetType()); + actual.ShouldBe(cases[i]); + } + SdkValueBoundary.ToClr(Variant.From(default(ByteString))).ShouldBeNull(); + SdkValueBoundary.ToClr(Variant.From(ByteString.Empty)).ShouldBeOfType().ShouldBeEmpty(); + } + + [Fact] + public void Publications_detach_buffers_and_preserve_byte_array_wire_rank() + { + var node = Node("Byte", array: true); + var bytes = new byte[] { 1, 2 }; + _manager.WriteValue("v", bytes, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + bytes[0] = 99; + node.Value.TypeInfo.BuiltInType.ShouldBe(BuiltInType.Byte); + node.Value.TypeInfo.ValueRank.ShouldBe(ValueRanks.OneDimension); + SdkValueBoundary.ToClr(node.Value).ShouldBeOfType().ShouldBe([1, 2]); + var readback = SdkValueBoundary.ToClr(node.Value).ShouldBeOfType(); + readback[0] = 77; + SdkValueBoundary.ToClr(node.Value).ShouldBeOfType().ShouldBe([1, 2]); + var scalarBytes = SdkValueBoundary.FromClr(new byte[] { 1, 2 }); + scalarBytes.TypeInfo.BuiltInType.ShouldBe(BuiltInType.ByteString); + scalarBytes.TypeInfo.ValueRank.ShouldBe(ValueRanks.Scalar); + } + + private BaseDataVariableState Node(string type, bool array = false, bool refuse = false, + bool writable = true, AddressSpaceRealm realm = AddressSpaceRealm.Raw) + { + _manager.EnsureVariable("v", null, "Value", type, writable, realm, + isArray: array, arrayLength: array ? 2u : null, refuseArrayOnScalar: refuse); + return _manager.TryGetVariable("v", realm)!; + } + + private ServerSystemContext Context(bool authorized = true) => new(_server.CurrentInstance) + { + UserIdentity = new RoleCarryingUserIdentity(new AnonymousIdentityToken(), + authorized ? [OpcUaDataPlaneRoles.WriteOperate] : [], "operator", null), + }; + + private ServiceResult Write(BaseDataVariableState node, object? value, bool authorized = true) => + node.WriteAttribute(Context(authorized), Attributes.Value, default, new DataValue(SdkValueBoundary.FromClr(value))); + + private sealed class RecordingGateway : IOpcUaNodeWriteGateway + { + internal List Values { get; } = []; + public Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) + { + Values.Add(value); + return Task.FromResult(new NodeWriteOutcome(true, null)); + } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs index 0611c42f..007b841b 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs @@ -323,20 +323,15 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable condition.Parent!.NodeId.ShouldBe(new NodeId("eq-1", unsNs)); // Initial state set by MaterialiseAlarmCondition: enabled, inactive, acked, retain=false. - condition.EnabledState.Id.Value.ShouldBeTrue(); - condition.ActiveState.Id.Value.ShouldBeFalse(); - condition.Retain.Value.ShouldBeFalse(); + condition.EnabledState!.Id!.Value.ShouldBeTrue(); + condition.ActiveState!.Id!.Value.ShouldBeFalse(); + condition.Retain!.Value.ShouldBeFalse(); // --- T13 optional-children finding (RESOLVED by THIS real-server test) --- // AckedState is mandatory on AcknowledgeableConditionState so it is always present. condition.AckedState.ShouldNotBeNull(); - // FINDING (1.5.378.106): Create auto-builds the FULL optional Part 9 child set from the - // embedded type definition WITHOUT us pre-setting any property — both ConfirmedState (Confirm - // sub-state machine) AND ShelvingState (Shelve state machine) come back non-null. This is - // RICHER than the SDK-notes' [SAMPLE-ONLY] caveat predicted (it suggested we'd have to - // instantiate optional children ourselves). Net: T15/T16 can drive SetConfirmedState / - // SetShelvingState directly — no manual child materialisation needed. Asserting both non-null - // so a future SDK bump that changes auto-build behaviour fails loudly. + // SDK 2.0 only builds the optional children explicitly requested by the application. + // The existing confirm/shelve workflow remains part of the served condition contract. condition.ConfirmedState.ShouldNotBeNull(); condition.ShelvingState.ShouldNotBeNull(); @@ -345,9 +340,9 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable sink.WriteAlarmCondition("alm-1", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns); nm.VariableCount.ShouldBe(0); // fallback bool[2] path NOT taken - condition.ActiveState.Id.Value.ShouldBeTrue(); - condition.AckedState.Id.Value.ShouldBeFalse(); - condition.Retain.Value.ShouldBeTrue(); // active || !acked ⇒ retain + condition.ActiveState!.Id!.Value.ShouldBeTrue(); + condition.AckedState!.Id!.Value.ShouldBeFalse(); + condition.Retain!.Value.ShouldBeTrue(); // active || !acked ⇒ retain // Idempotent re-materialise (e.g. redeploy): still exactly one condition node for the id. sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); @@ -410,17 +405,17 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable Message: "Pressure above limit"), DateTime.UtcNow, AddressSpaceRealm.Uns); - condition.ActiveState.Id.Value.ShouldBeTrue(); - condition.AckedState.Id.Value.ShouldBeFalse(); - condition.EnabledState.Id.Value.ShouldBeFalse(); // disabled - condition.ConfirmedState!.Id.Value.ShouldBeFalse(); // unconfirmed + condition.ActiveState!.Id!.Value.ShouldBeTrue(); + condition.AckedState!.Id!.Value.ShouldBeFalse(); + condition.EnabledState!.Id!.Value.ShouldBeFalse(); // disabled + condition.ConfirmedState!.Id!.Value.ShouldBeFalse(); // unconfirmed // SetShelvingState(shelved:true, oneShot:false) drives the shelving sub-state machine into a // shelved state; CurrentState is populated (TimedShelved). - condition.ShelvingState!.CurrentState.Id.Value.ShouldNotBeNull(); + condition.ShelvingState!.CurrentState!.Id!.Value.IsNull.ShouldBeFalse(); // Severity 850 is carried verbatim — not re-bucketed onto EventSeverity.High (900). - condition.Severity.Value.ShouldBe((ushort)850); - condition.Message.Value.Text.ShouldBe("Pressure above limit"); - condition.Retain.Value.ShouldBeTrue(); // active || !acked ⇒ retain + condition.Severity!.Value.ShouldBe((ushort)850); + condition.Message!.Value.Text.ShouldBe("Pressure above limit"); + condition.Retain!.Value.ShouldBeTrue(); // active || !acked ⇒ retain await host.DisposeAsync(); } @@ -462,9 +457,9 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable DateTime.UtcNow, AddressSpaceRealm.Uns)); // Mandatory state still projected despite the missing optional child. - condition.ActiveState.Id.Value.ShouldBeTrue(); - condition.AckedState.Id.Value.ShouldBeFalse(); - condition.Message.Value.Text.ShouldBe("still works"); + condition.ActiveState!.Id!.Value.ShouldBeTrue(); + condition.AckedState!.Id!.Value.ShouldBeFalse(); + condition.Message!.Value.Text.ShouldBe("still works"); await host.DisposeAsync(); } @@ -495,12 +490,12 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable // EventId at materialise time (Create stamps an initial one). Capture a COPY — the live byte[] // reference is reused, so we compare snapshots, not the same array instance. - var materialiseEventId = (byte[]?)condition!.EventId.Value?.Clone(); + var materialiseEventId = (byte[]?)condition!.EventId!.Value.ToArray(); // First engine-driven transition → fires an event with a fresh EventId. sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns); - var firstEventId = (byte[]?)condition.EventId.Value?.Clone(); + var firstEventId = (byte[]?)condition.EventId!.Value.ToArray(); firstEventId.ShouldNotBeNull(); firstEventId!.Length.ShouldBe(16); // GUID bytes // Changed from the materialise-time value (a real event fired, not the create-time stamp). @@ -512,7 +507,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable // Second transition → DIFFERENT EventId (fresh per event, so T17 ack-correlation is unambiguous). sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: true), DateTime.UtcNow, AddressSpaceRealm.Uns); - var secondEventId = (byte[]?)condition.EventId.Value?.Clone(); + var secondEventId = (byte[]?)condition.EventId!.Value.ToArray(); secondEventId.ShouldNotBeNull(); secondEventId!.ShouldNotBe(firstEventId); @@ -551,10 +546,10 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable }); // Final projected state is intact after the last firing. - condition!.ActiveState.Id.Value.ShouldBeFalse(); - condition.AckedState.Id.Value.ShouldBeTrue(); - condition.Message.Value.Text.ShouldBe("cleared"); - condition.Retain.Value.ShouldBeFalse(); // inactive && acked ⇒ no retain + condition!.ActiveState!.Id!.Value.ShouldBeFalse(); + condition.AckedState!.Id!.Value.ShouldBeTrue(); + condition.Message!.Value.Text.ShouldBe("cleared"); + condition.Retain!.Value.ShouldBeFalse(); // inactive && acked ⇒ no retain await host.DisposeAsync(); } @@ -583,8 +578,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable // Drive the alarm active+unacked through the engine path (a genuine transition → fires). sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns); - condition!.ActiveState.Id.Value.ShouldBeTrue(); - condition.AckedState.Id.Value.ShouldBeFalse(); + condition!.ActiveState!.Id!.Value.ShouldBeTrue(); + condition.AckedState!.Id!.Value.ShouldBeFalse(); // === Simulate the SDK-applied inbound Acknowledge (E2) === // After T18's gate returns Good, the SDK applies the acked state to the node and auto-fires its @@ -592,26 +587,26 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable lock (nm.Lock) { condition.SetAcknowledgedState(nm.SystemContext, true); - condition.Message.Value = new LocalizedText("active"); + condition.Message!.Value = new LocalizedText("active"); condition.ClearChangeMasks(nm.SystemContext, includeChildren: true); } - condition.AckedState.Id.Value.ShouldBeTrue(); + condition.AckedState!.Id!.Value.ShouldBeTrue(); // Capture the EventId AFTER the SDK-applied ack but BEFORE the engine re-projection. - var beforeReProject = (byte[]?)condition.EventId.Value?.Clone(); + var beforeReProject = (byte[]?)condition.EventId!.Value.ToArray(); // === Engine re-projects the SAME acked transition through WriteAlarmCondition (would-be E3) === // Snapshot equals the node's current state (active, acked, message "active") ⇒ delta-gate sees // no change ⇒ NO event fires. sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: true, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns); - var afterReProject = (byte[]?)condition.EventId.Value?.Clone(); + var afterReProject = (byte[]?)condition.EventId!.Value.ToArray(); // EventId is UNCHANGED ⇒ ReportConditionEvent did NOT run ⇒ E3 suppressed. afterReProject.ShouldBe(beforeReProject); // The projection still applied (state is intact) — only the redundant event was suppressed. - condition.ActiveState.Id.Value.ShouldBeTrue(); - condition.AckedState.Id.Value.ShouldBeTrue(); + condition.ActiveState!.Id!.Value.ShouldBeTrue(); + condition.AckedState!.Id!.Value.ShouldBeTrue(); await host.DisposeAsync(); } @@ -633,24 +628,24 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable var condition = nm.TryGetAlarmCondition("alm-delta"); condition.ShouldNotBeNull(); - var beforeFirst = (byte[]?)condition!.EventId.Value?.Clone(); + var beforeFirst = (byte[]?)condition!.EventId!.Value.ToArray(); // Genuine transition: snapshot (active, unacked) differs from the materialise state // (inactive, acked) ⇒ delta ⇒ fires exactly one event (EventId changes). sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns); - var afterFirst = (byte[]?)condition.EventId.Value?.Clone(); + var afterFirst = (byte[]?)condition.EventId!.Value.ToArray(); afterFirst.ShouldNotBeNull(); afterFirst!.ShouldNotBe(beforeFirst); // fired // Identical re-projection: snapshot now EQUALS the node's current state ⇒ no delta ⇒ 0 more // events (EventId unchanged from the first fire). sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns); - var afterSecond = (byte[]?)condition.EventId.Value?.Clone(); + var afterSecond = (byte[]?)condition.EventId!.Value.ToArray(); afterSecond.ShouldBe(afterFirst); // suppressed // A FURTHER genuine transition (clear) differs again ⇒ fires once more. sink.WriteAlarmCondition("alm-delta", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow, AddressSpaceRealm.Uns); - var afterThird = (byte[]?)condition.EventId.Value?.Clone(); + var afterThird = (byte[]?)condition.EventId!.Value.ToArray(); afterThird.ShouldNotBe(afterSecond); // fired await host.DisposeAsync(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkServiceLevelPublisherTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkServiceLevelPublisherTests.cs index 13fdaf85..f97569af 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkServiceLevelPublisherTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkServiceLevelPublisherTests.cs @@ -1,3 +1,4 @@ +using Opc.Ua; using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua.Server; using Shouldly; @@ -34,7 +35,7 @@ public sealed class SdkServiceLevelPublisherTests : IDisposable var proxy = (FaultingServer)(object)server; proxy.Node = new Opc.Ua.ServerObjectState(null) { - ServiceLevel = new Opc.Ua.PropertyState(null), + ServiceLevel = new Opc.Ua.PropertyState.Implementation(null), }; proxy.Fault = new InvalidOperationException("controlled diagnostics failure"); var publisher = new SdkServiceLevelPublisher(server, NullLogger.Instance); @@ -48,7 +49,7 @@ public sealed class SdkServiceLevelPublisherTests : IDisposable protected override object? Invoke(System.Reflection.MethodInfo? method, object?[]? args) { if (method?.Name == "get_ServerObject") return Node; - if (method?.Name == "get_DiagnosticsLock") throw Fault!; + if (method?.Name == "UpdateServerDiagnostics") throw Fault!; throw new NotSupportedException(method?.Name); } } @@ -69,7 +70,7 @@ public sealed class SdkServiceLevelPublisherTests : IDisposable PkiStoreRoot = _pkiRoot, }, NullLogger.Instance), - () => new StandardServer(), Ct); + () => new OtOpcUaSdkServer(), Ct); await using var host = started.Host; var server = started.Server; @@ -100,7 +101,7 @@ public sealed class SdkServiceLevelPublisherTests : IDisposable PkiStoreRoot = _pkiRoot, }, NullLogger.Instance), - () => new StandardServer(), Ct); + () => new OtOpcUaSdkServer(), Ct); await using var host = started.Host; var server = started.Server; var publisher = new SdkServiceLevelPublisher( @@ -111,7 +112,7 @@ public sealed class SdkServiceLevelPublisherTests : IDisposable publisher.Publish(150); publisher.Publish(240); - server.CurrentInstance.ServerObject.ServiceLevel.Value.ShouldBe((byte)240); + server.CurrentInstance.ServerObject.ServiceLevel!.Value.ShouldBe((byte)240); } /// Disposes and cleans up the test resources. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkTestValues.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkTestValues.cs new file mode 100644 index 00000000..9109c87b --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkTestValues.cs @@ -0,0 +1,8 @@ +using Opc.Ua; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +internal static class SdkTestValues +{ + internal static byte[]? ToNullableArray(this ByteString value) => value.IsEmpty ? null : value.ToArray(); +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SessionHistoryContinuationStoreTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SessionHistoryContinuationStoreTests.cs new file mode 100644 index 00000000..354e47dd --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SessionHistoryContinuationStoreTests.cs @@ -0,0 +1,98 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Server; +using Shouldly; +using Xunit; +using Opc.Ua.Security.Certificates; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +public sealed class SessionHistoryContinuationStoreTests : IAsyncLifetime +{ + private readonly string _pki = Path.Combine(Path.GetTempPath(), $"otua-sdk2-cp-{Guid.NewGuid():N}"); + private OpcUaApplicationHost _host = null!; + private OtOpcUaSdkServer _server = null!; + private Certificate _certificate = null!; + private Nonce _nonce = null!; + private readonly SessionHistoryContinuationStore _store = new(); + + public async ValueTask InitializeAsync() + { + var started = await OpcUaHostStart.StartAsync(port => new OpcUaApplicationHost(new OpcUaApplicationHostOptions + { + ApplicationName = "SDK2.Continuation", ApplicationUri = "urn:SDK2.Continuation", + PublicHostname = "localhost", OpcUaPort = port, PkiStoreRoot = _pki, + }, NullLogger.Instance), () => new OtOpcUaSdkServer(), TestContext.Current.CancellationToken); + _host = started.Host; + _server = started.Server; + using var key = RSA.Create(2048); + var request = new CertificateRequest("CN=SDK2.Continuation.Unit", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + _certificate = Certificate.From(request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1))); + _nonce = Nonce.CreateNonce(32); + } + + public async ValueTask DisposeAsync() + { + _nonce.Dispose(); + _certificate.Dispose(); + await _host.DisposeAsync(); + Directory.Delete(_pki, true); + } + + [Fact] + public void Real_session_store_transfers_an_owned_single_use_wrapper() + { + using var session = Session(); + var state = State(); + var token = _store.Save(session, state)!; + token.Length.ShouldBe(16); + using var point = session.ContinuationPoints.RestoreHistory(ByteString.From(token)); + point.ShouldNotBeNull(); + point.Id.ShouldBe(new Guid(token)); + _store.TryTake(session, token).ShouldBeNull(); + var next = _store.Save(session, state)!; + _store.TryTake(session, next).ShouldBeSameAs(state); + _store.TryTake(session, next).ShouldBeNull(); + } + + [Fact] + public void Real_sessions_isolate_points_evict_oldest_and_release_explicitly() + { + using var first = Session(); + using var second = Session(); + var oldest = _store.Save(first, State())!; + var middle = _store.Save(first, State())!; + var latest = _store.Save(first, State())!; + _store.TryTake(first, oldest).ShouldBeNull(); + _store.TryTake(second, middle).ShouldBeNull(); + _store.TryTake(first, middle).ShouldNotBeNull(); + _store.Release(first, latest); + _store.TryTake(first, latest).ShouldBeNull(); + _store.Release(first, latest); + _store.TryTake(first, [1, 2, 3]).ShouldBeNull(); + } + + [Fact] + public void Closing_the_real_session_drops_abandoned_points() + { + var session = Session(); + var token = _store.Save(session, State())!; + session.Dispose(); + _store.TryTake(session, token).ShouldBeNull(); + } + + private Session Session() + { + var endpoint = new EndpointDescription { EndpointUrl = "opc.tcp://localhost:4840", SecurityMode = MessageSecurityMode.None }; + using var context = new OperationContext(new RequestHeader(), + new SecureChannelContext(Guid.NewGuid().ToString(), endpoint, RequestEncoding.Binary), + RequestType.CreateSession, RequestLifetime.None); + return new Session(context, _server.CurrentInstance, _certificate, new NodeId(Guid.NewGuid()), default, + _nonce, "SDK2 continuation unit", new ApplicationDescription(), endpoint.EndpointUrl, + null!, null!, 60_000, 2, 2); + } + + private static HistoryContinuationState State() => new("tag", DateTime.UtcNow, DateTime.UtcNow.AddHours(1), 0, 2); +}