diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientBrowseSessionAttributesTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientBrowseSessionAttributesTests.cs index c8c13c09..d59a2855 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientBrowseSessionAttributesTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientBrowseSessionAttributesTests.cs @@ -20,23 +20,23 @@ public sealed class OpcUaClientBrowseSessionAttributesTests private static Mock SessionReturning(NodeClass nodeClass, NodeId dataType, int valueRank, byte userAccess) { var mock = new Mock(MockBehavior.Loose); - mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(BrowseTelemetry.Context)); + mock.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(BrowseTelemetry.Context)); mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); mock.Setup(s => s.ReadAsync( It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) - .ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) => + It.IsAny>(), It.IsAny())) + .ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ArrayOf nodes, CancellationToken _) => new ReadResponse { ResponseHeader = new ResponseHeader(), - Results = [.. nodes.Select(n => n.AttributeId switch + Results = [.. nodes.ToList().Select(n => n.AttributeId switch { Attributes.NodeClass => new DataValue(new Variant((int)nodeClass)), Attributes.DisplayName => new DataValue(new Variant(new LocalizedText("Speed"))), Attributes.DataType => new DataValue(new Variant(dataType)), Attributes.ValueRank => new DataValue(new Variant(valueRank)), Attributes.UserAccessLevel => new DataValue(new Variant(userAccess)), - _ => new DataValue(StatusCodes.BadAttributeIdInvalid), + _ => DataValue.FromStatusCode(StatusCodes.BadAttributeIdInvalid), })], DiagnosticInfos = [], }); @@ -101,19 +101,19 @@ public sealed class OpcUaClientBrowseSessionAttributesTests session.AttributesAsync("nsu=urn:not-in-table;s=X", TestContext.Current.CancellationToken)); } - private sealed class TrackingCredential : IDisposable + private sealed class TrackingApplication : IAsyncDisposable { public int Disposed { get; private set; } - public void Dispose() => Disposed++; + public ValueTask DisposeAsync() { Disposed++; return ValueTask.CompletedTask; } } - /// Review m9: the browse session disposes the user certificate it was handed, with itself. + /// Review m9: the browse session disposes the SDK application it was handed, with itself. [Fact] - public async Task The_session_disposes_the_credential_it_owns() + public async Task The_session_disposes_the_application_it_owns() { var mock = SessionReturning(NodeClass.Variable, DataTypeIds.Int32, -1, 1); - var credential = new TrackingCredential(); + var credential = new TrackingApplication(); var session = new OpcUaClientBrowseSession( mock.Object, NamespaceMap.FromTable(new NamespaceTable()), ObjectIds.ObjectsFolder, credential); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientBrowseSessionPaginationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientBrowseSessionPaginationTests.cs index 28811140..b89eaa69 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientBrowseSessionPaginationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientBrowseSessionPaginationTests.cs @@ -20,7 +20,7 @@ public sealed class OpcUaClientBrowseSessionPaginationTests private static Mock BuildSession(bool cancelNext) { var mock = new Mock(MockBehavior.Loose); - mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(BrowseTelemetry.Context)); + mock.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(BrowseTelemetry.Context)); mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); var firstRef = new ReferenceDescription @@ -32,44 +32,44 @@ public sealed class OpcUaClientBrowseSessionPaginationTests }; mock.Setup(s => s.BrowseAsync( It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny>(), It.IsAny())) .ReturnsAsync(new BrowseResponse { ResponseHeader = new ResponseHeader(), - Results = new BrowseResultCollection + Results = new List { new BrowseResult { - ContinuationPoint = FakeContinuationPoint, - References = new ReferenceDescriptionCollection { firstRef }, + ContinuationPoint = FakeContinuationPoint.ToByteString(), + References = new List { firstRef }, }, }, - DiagnosticInfos = new DiagnosticInfoCollection(), + DiagnosticInfos = new List(), }); var emptyPage = new BrowseNextResponse { ResponseHeader = new ResponseHeader(), - Results = new BrowseResultCollection + Results = new List { new BrowseResult { - ContinuationPoint = Array.Empty(), - References = new ReferenceDescriptionCollection(), + ContinuationPoint = ByteString.Empty, + References = new List(), }, }, - DiagnosticInfos = new DiagnosticInfoCollection(), + DiagnosticInfos = new List(), }; var pageSetup = mock.Setup(s => s.BrowseNextAsync( It.IsAny(), It.Is(r => !r), - It.IsAny(), It.IsAny())); + It.IsAny>(), It.IsAny())); if (cancelNext) pageSetup.ThrowsAsync(new OperationCanceledException("cancelled mid-page")); else pageSetup.ReturnsAsync(emptyPage); mock.Setup(s => s.BrowseNextAsync( It.IsAny(), It.Is(r => r), - It.IsAny(), It.IsAny())) + It.IsAny>(), It.IsAny())) .ReturnsAsync(emptyPage); return mock; } @@ -89,7 +89,7 @@ public sealed class OpcUaClientBrowseSessionPaginationTests mock.Verify(s => s.BrowseNextAsync( It.IsAny(), It.Is(r => r), - It.IsAny(), It.IsAny()), Times.Once); + It.IsAny>(), It.IsAny()), Times.Once); } /// Positive control: an uncancelled pagination never issues a release call. @@ -104,9 +104,9 @@ public sealed class OpcUaClientBrowseSessionPaginationTests nodes.Count.ShouldBe(1); mock.Verify(s => s.BrowseNextAsync( It.IsAny(), It.Is(r => !r), - It.IsAny(), It.IsAny()), Times.Once); + It.IsAny>(), It.IsAny()), Times.Once); mock.Verify(s => s.BrowseNextAsync( It.IsAny(), It.Is(r => r), - It.IsAny(), It.IsAny()), Times.Never); + It.IsAny>(), It.IsAny()), Times.Never); } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientDriverBrowserTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientDriverBrowserTests.cs index 2ae84ef6..08fc5825 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientDriverBrowserTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientDriverBrowserTests.cs @@ -120,7 +120,7 @@ public sealed class OpcUaClientDriverBrowserTests // A loose mock answers the attribute read with no response, so nothing is known about the node. var mockSession = new Mock(MockBehavior.Loose); mockSession.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); - mockSession.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(BrowseTelemetry.Context)); + mockSession.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(BrowseTelemetry.Context)); var nsMap = NamespaceMap.FromTable(new NamespaceTable()); var sut = new OpcUaClientBrowseSession(mockSession.Object, nsMap, ObjectIds.ObjectsFolder); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientAcknowledgeFailureTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientAcknowledgeFailureTests.cs index 101da754..acdd5b88 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientAcknowledgeFailureTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientAcknowledgeFailureTests.cs @@ -21,7 +21,7 @@ public sealed class OpcUaClientAcknowledgeFailureTests private static Mock NewSession() { var mock = new Mock(MockBehavior.Loose); - mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context)); + mock.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(SdkTelemetry.Context)); mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); return mock; } @@ -34,7 +34,7 @@ public sealed class OpcUaClientAcknowledgeFailureTests { using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-o8"); var session = NewSession(); - session.Setup(s => s.CallAsync(It.IsAny(), It.IsAny(), It.IsAny())) + session.Setup(s => s.CallAsync(It.IsAny(), It.IsAny>(), It.IsAny())) .ThrowsAsync(new ServiceResultException(StatusCodes.BadConnectionClosed)); drv.SetSessionForTest(session.Object); @@ -50,7 +50,7 @@ public sealed class OpcUaClientAcknowledgeFailureTests { using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-o8-ok"); var session = NewSession(); - session.Setup(s => s.CallAsync(It.IsAny(), It.IsAny(), It.IsAny())) + session.Setup(s => s.CallAsync(It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync(new CallResponse { ResponseHeader = new ResponseHeader(), @@ -61,10 +61,30 @@ public sealed class OpcUaClientAcknowledgeFailureTests await drv.AcknowledgeAsync(Ack(), TestContext.Current.CancellationToken); - session.Verify(s => s.CallAsync(It.IsAny(), It.IsAny(), It.IsAny()), + session.Verify(s => s.CallAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Once); } + [Fact] + public async Task Acknowledge_encodes_EventId_as_scalar_ByteString_and_never_retries_a_transport_failure() + { + using var driver = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-ack-types"); + var session = NewSession(); + ArrayOf sent = default; + session.Setup(s => s.CallAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>((_, calls, _) => sent = calls) + .ThrowsAsync(new ServiceResultException(StatusCodes.BadConnectionClosed)); + driver.SetSessionForTest(session.Object); + await Should.ThrowAsync(() => driver.AcknowledgeAsync(Ack(), TestContext.Current.CancellationToken)); + var arguments = sent[0].InputArguments; + arguments[0].TypeInfo.BuiltInType.ShouldBe(BuiltInType.ByteString); + arguments[0].TypeInfo.ValueRank.ShouldBe(ValueRanks.Scalar); + arguments[0].GetByteString().IsEmpty.ShouldBeTrue(); + arguments[1].TypeInfo.BuiltInType.ShouldBe(BuiltInType.LocalizedText); + arguments[1].GetLocalizedText().Text.ShouldBe("operator ack"); + session.Verify(s => s.CallAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Once); + } + /// A session lost while the ack queued on the gate fails the ack rather than reporting it applied. [Fact] public async Task A_session_lost_while_queued_fails_the_ack() @@ -110,7 +130,7 @@ public sealed class OpcUaClientAcknowledgeFailureTests await drv.AcknowledgeAsync(Unresolvable(), TestContext.Current.CancellationToken); - session.Verify(s => s.CallAsync(It.IsAny(), It.IsAny(), It.IsAny()), + session.Verify(s => s.CallAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); lock (logger.Entries) logger.Entries.ShouldContain(e => e.Level == Microsoft.Extensions.Logging.LogLevel.Warning && e.Message.Contains("resolved none")); @@ -127,7 +147,7 @@ public sealed class OpcUaClientAcknowledgeFailureTests var ct = TestContext.Current.CancellationToken; using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-n1"); var session = NewSession(); - session.Setup(s => s.CallAsync(It.IsAny(), It.IsAny(), It.IsAny())) + session.Setup(s => s.CallAsync(It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync(new CallResponse { ResponseHeader = new ResponseHeader(), @@ -154,7 +174,7 @@ public sealed class OpcUaClientAcknowledgeFailureTests await invoker.ExecuteAsync(DriverCapability.AlarmAcknowledge, "host", async c => await drv.AcknowledgeAsync(Ack(), c), ct); - session.Verify(s => s.CallAsync(It.IsAny(), It.IsAny(), It.IsAny()), + session.Verify(s => s.CallAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Once, "the genuine ack must still reach the server"); } @@ -166,7 +186,7 @@ public sealed class OpcUaClientAcknowledgeFailureTests var stamp = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); drv.SetHealthForTest(new DriverHealth(DriverState.Healthy, stamp, null)); var session = NewSession(); - session.Setup(s => s.CallAsync(It.IsAny(), It.IsAny(), It.IsAny())) + session.Setup(s => s.CallAsync(It.IsAny(), It.IsAny>(), It.IsAny())) .ThrowsAsync(new ServiceResultException(StatusCodes.BadConnectionClosed)); drv.SetSessionForTest(session.Object); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientAlarmTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientAlarmTests.cs index b9a3d06b..10d0b9be 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientAlarmTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientAlarmTests.cs @@ -76,9 +76,9 @@ public sealed class OpcUaClientAlarmTests // ---- #619: v3 RawPath <-> upstream-source translation on the native-alarm path ---- - private static VariantCollection EventFields(string sourceNode, string conditionNode) => + private static List EventFields(string sourceNode, string conditionNode) => [ - new Variant(new byte[] { 1 }), // EventId + Variant.From(new byte[] { 1 }.ToByteString()), // scalar ByteString EventId new Variant(new NodeId("TripAlarmType", 3)), // EventType new Variant(NodeId.Parse(sourceNode)), // SourceNode new Variant(new LocalizedText("tripped")), // Message @@ -310,18 +310,18 @@ public sealed class OpcUaClientAlarmTests } } - private static VariantCollection ConditionFields( + private static List ConditionFields( bool active, bool acked, string message = "tripped", ushort severity = 700, byte[]? eventId = null, NodeId? branchId = null, NodeId? eventType = null) { var f = EventFields("ns=3;s=Alarms.Trip1", "ns=3;s=Alarms.Trip1.Cond"); - f[0] = new Variant(eventId ?? new byte[] { 1 }); - if (eventType is not null) f[1] = new Variant(eventType); + f[0] = Variant.From(new ByteString((eventId ?? new byte[] { 1 }).AsMemory())); + if (eventType is not null) f[1] = Variant.From(eventType.Value); f[3] = new Variant(new LocalizedText(message)); f[4] = new Variant(severity); f.Add(new Variant(active)); f.Add(new Variant(acked)); - f.Add(branchId is null ? Variant.Null : new Variant(branchId)); + f.Add(branchId is null ? Variant.Null : Variant.From(branchId.Value)); return f; } @@ -382,7 +382,7 @@ public sealed class OpcUaClientAlarmTests OpcUaClientDriver.IsRefreshRequired(EventFieldsOfType(ObjectTypeIds.RefreshEndEventType)).ShouldBeFalse(); } - private static VariantCollection EventFieldsOfType(NodeId type) + private static List EventFieldsOfType(NodeId type) { var f = EventFields("i=2253", "i=0"); f[1] = new Variant(type); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientCertAuthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientCertAuthTests.cs index b3886f03..19fd456e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientCertAuthTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientCertAuthTests.cs @@ -65,4 +65,49 @@ public sealed class OpcUaClientCertAuthTests try { File.Delete(tmpPath); } catch { /* best-effort */ } } } + [Fact] + public async Task Certificate_identity_signs_repeatedly_and_requires_reopen_after_file_rotation() + { + var path = Path.Combine(Path.GetTempPath(), $"opcua-cert-rotation-{Guid.NewGuid():N}.pfx"); + try + { + using var key = RSA.Create(2048); + var request = new CertificateRequest("CN=OpcUaSdk2Signing", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using var original = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1)); + File.WriteAllBytes(path, original.Export(X509ContentType.Pfx, "test-password")); + var options = new OpcUaClientDriverOptions { AuthType = OpcUaAuthType.Certificate, + UserCertificatePath = path, UserCertificatePassword = "test-password" }; + var identity = OpcUaClientDriver.BuildCertificateIdentity(options); + byte[] challenge = [1, 2, 3, 4]; + for (var i = 0; i < 2; i++) + { + var signature = await identity.TokenHandler.SignAsync(challenge, Opc.Ua.SecurityPolicies.Basic256Sha256, + TestContext.Current.CancellationToken); + using var publicKey = original.GetRSAPublicKey(); + publicKey.ShouldNotBeNull().VerifyData(challenge, signature.Signature.Span, + HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1).ShouldBeTrue(); + } + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + await Should.ThrowAsync(async () => + await identity.TokenHandler.SignAsync(challenge, Opc.Ua.SecurityPolicies.Basic256Sha256, cancelled.Token)); + + using var rotatedKey = RSA.Create(2048); + var rotatedRequest = new CertificateRequest("CN=OpcUaSdk2Signing", rotatedKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using var rotated = rotatedRequest.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1)); + File.WriteAllBytes(path, rotated.Export(X509ContentType.Pfx, "test-password")); + var error = await Should.ThrowAsync(async () => + await identity.TokenHandler.SignAsync(challenge, Opc.Ua.SecurityPolicies.Basic256Sha256, + TestContext.Current.CancellationToken)); + error.StatusCode.ShouldBe(Opc.Ua.StatusCodes.BadIdentityTokenInvalid); + var fresh = OpcUaClientDriver.BuildCertificateIdentity(options); + var freshSignature = await fresh.TokenHandler.SignAsync(challenge, Opc.Ua.SecurityPolicies.Basic256Sha256, + TestContext.Current.CancellationToken); + using var rotatedPublicKey = rotated.GetRSAPublicKey(); + rotatedPublicKey.ShouldNotBeNull().VerifyData(challenge, freshSignature.Signature.Span, + HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1).ShouldBeTrue(); + } + finally { File.Delete(path); } + } + } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientConnectionLossSignalTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientConnectionLossSignalTests.cs index ae0ffe91..29f9c585 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientConnectionLossSignalTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientConnectionLossSignalTests.cs @@ -59,7 +59,7 @@ public sealed class OpcUaClientConnectionLossSignalTests private static Mock NewSession() { var mock = new Mock(MockBehavior.Loose); - mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context)); + mock.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(SdkTelemetry.Context)); mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); return mock; } @@ -77,18 +77,18 @@ public sealed class OpcUaClientConnectionLossSignalTests return (drv, session, clock, log); } - private static DataValue Good(object value, DateTime ts) => new(new Variant(value), StatusCodes.Good, ts, ts); + private static DataValue Good(object value, DateTime ts) => new(VariantHelper.CastFrom(value), StatusCodes.Good, ts, ts); /// Every read returns (one per requested node, in order) at . private static void SetupRead(Mock session, Func at, params object[] values) => session.Setup(s => s.ReadAsync( It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) - .ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) => + It.IsAny>(), It.IsAny())) + .ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ArrayOf nodes, CancellationToken _) => new ReadResponse { ResponseHeader = new ResponseHeader(), - Results = [.. nodes.Select((_, i) => Good(values[i], at()))], + Results = [.. nodes.ToList().Select((_, i) => Good(values[i], at()))], DiagnosticInfos = [], }); @@ -96,18 +96,18 @@ public sealed class OpcUaClientConnectionLossSignalTests public static TheoryData DefinitiveStatuses => [ - StatusCodes.BadSessionIdInvalid, - StatusCodes.BadSessionClosed, - StatusCodes.BadSessionNotActivated, - StatusCodes.BadServerHalted, + StatusCodes.BadSessionIdInvalid.Code, + StatusCodes.BadSessionClosed.Code, + StatusCodes.BadSessionNotActivated.Code, + StatusCodes.BadServerHalted.Code, ]; public static TheoryData AmbiguousStatuses => [ - StatusCodes.BadNoCommunication, - StatusCodes.BadNotConnected, - StatusCodes.BadRequestTimeout, - StatusCodes.BadTimeout, + StatusCodes.BadNoCommunication.Code, + StatusCodes.BadNotConnected.Code, + StatusCodes.BadRequestTimeout.Code, + StatusCodes.BadTimeout.Code, ]; [Theory] @@ -127,7 +127,7 @@ public sealed class OpcUaClientConnectionLossSignalTests drv.RaiseKeepAliveForTest(StatusCodes.BadSessionIdInvalid); raised.ShouldHaveSingleItem("once per session, however many definitive keep-alives follow") - .Reason.ShouldContain(StatusCodes.GetBrowseName(status)); + .Reason.ShouldContain(new StatusCode(status).SymbolicId!); order.ShouldBe([$"bad:{BadNoCommunication:X8}", "lost"], "subscribers see the Bad before the host tears down"); } @@ -404,7 +404,7 @@ public sealed class OpcUaClientConnectionLossSignalTests var reads = 0; session.Setup(s => s.ReadAsync( It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny>(), It.IsAny())) .ReturnsAsync(() => { var trailing = Interlocked.Increment(ref reads) > 1; @@ -447,7 +447,7 @@ public sealed class OpcUaClientConnectionLossSignalTests var reads = 0; session.Setup(s => s.ReadAsync( It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny>(), It.IsAny())) .Returns(async () => { if (Interlocked.Increment(ref reads) > 1) await gate.Task; // hold the trailing read @@ -495,7 +495,7 @@ public sealed class OpcUaClientConnectionLossSignalTests session.Verify(s => s.ReadAsync( It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny()), Times.Once, + It.IsAny>(), It.IsAny()), Times.Once, "only the recovery read — the trailing read must not run against a session that is no longer current"); } @@ -506,7 +506,7 @@ public sealed class OpcUaClientConnectionLossSignalTests var reads = 0; session.Setup(s => s.ReadAsync( It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny>(), It.IsAny())) .ReturnsAsync(() => new ReadResponse { ResponseHeader = new ResponseHeader(), @@ -559,7 +559,7 @@ public sealed class OpcUaClientConnectionLossSignalTests var handle = drv.AddSubscriptionForTest([SpeedRef]); var t0 = clock.Now.UtcDateTime; DataValue NoSource(object v, DateTime server) => - new(new Variant(v), StatusCodes.Good, DateTime.MinValue, server); + new(VariantHelper.CastFrom(v), StatusCodes.Good, DateTime.MinValue, server); drv.DeliverNotificationForTest(handle, SpeedRef, NoSource(10f, t0)); var reads = SetupRecoveryThenTrailing(session, NoSource(10f, t0.AddSeconds(1)), NoSource(40f, t0.AddSeconds(9))); @@ -587,7 +587,7 @@ public sealed class OpcUaClientConnectionLossSignalTests var reads = 0; session.Setup(s => s.ReadAsync( It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny>(), It.IsAny())) .ReturnsAsync(() => new ReadResponse { ResponseHeader = new ResponseHeader(), @@ -677,12 +677,12 @@ public sealed class OpcUaClientConnectionLossSignalTests drv.PostRecoveryReconcileDelay = () => TimeSpan.Zero; var handle = drv.AddSubscriptionForTest([SpeedRef]); var t0 = clock.Now.UtcDateTime; - DataValue NoSource(object v, DateTime server) => new(new Variant(v), StatusCodes.Good, DateTime.MinValue, server); + DataValue NoSource(object v, DateTime server) => new(VariantHelper.CastFrom(v), StatusCodes.Good, DateTime.MinValue, server); drv.DeliverNotificationForTest(handle, SpeedRef, NoSource(10f, t0)); var reads = 0; session.Setup(s => s.ReadAsync( It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny>(), It.IsAny())) .ReturnsAsync(() => { DataValue result; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientContinuationPointReleaseTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientContinuationPointReleaseTests.cs index e4fcc323..62b9a6b1 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientContinuationPointReleaseTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientContinuationPointReleaseTests.cs @@ -37,7 +37,7 @@ public sealed class OpcUaClientContinuationPointReleaseTests private static Mock BuildCancellingSessionMock() { var mock = new Mock(MockBehavior.Loose); - mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.SdkTelemetry.Context)); + mock.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.SdkTelemetry.Context)); mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); // BrowseAsync — return one Object child plus a non-empty continuation point so @@ -52,20 +52,20 @@ public sealed class OpcUaClientContinuationPointReleaseTests }; var browseResult = new BrowseResult { - ContinuationPoint = FakeContinuationPoint, - References = new ReferenceDescriptionCollection { firstRef }, + ContinuationPoint = FakeContinuationPoint.ToByteString(), + References = new List { firstRef }, }; var browseResp = new BrowseResponse { ResponseHeader = new ResponseHeader(), - Results = new BrowseResultCollection { browseResult }, - DiagnosticInfos = new DiagnosticInfoCollection(), + Results = new List { browseResult }, + DiagnosticInfos = new List(), }; mock.Setup(s => s.BrowseAsync( It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), + It.IsAny>(), It.IsAny())) .ReturnsAsync(browseResp); @@ -73,7 +73,7 @@ public sealed class OpcUaClientContinuationPointReleaseTests mock.Setup(s => s.BrowseNextAsync( It.IsAny(), It.Is(release => !release), - It.IsAny(), + It.IsAny>(), It.IsAny())) .ThrowsAsync(new OperationCanceledException("ct cancelled during BrowseNext")); @@ -82,19 +82,19 @@ public sealed class OpcUaClientContinuationPointReleaseTests // there is no separate BrowseNextResult type in the OPC UA SDK. var emptyReleaseResult = new BrowseResult { - ContinuationPoint = Array.Empty(), - References = new ReferenceDescriptionCollection(), + ContinuationPoint = ByteString.Empty, + References = new List(), }; var releaseResp = new BrowseNextResponse { ResponseHeader = new ResponseHeader(), - Results = new BrowseResultCollection { emptyReleaseResult }, - DiagnosticInfos = new DiagnosticInfoCollection(), + Results = new List { emptyReleaseResult }, + DiagnosticInfos = new List(), }; mock.Setup(s => s.BrowseNextAsync( It.IsAny(), It.Is(release => release), - It.IsAny(), + It.IsAny>(), It.IsAny())) .ReturnsAsync(releaseResp); @@ -127,7 +127,7 @@ public sealed class OpcUaClientContinuationPointReleaseTests sessionMock.Verify(s => s.BrowseNextAsync( It.IsAny(), It.Is(release => release), - It.IsAny(), + It.IsAny>(), It.IsAny()), Times.Once, "DiscoverAsync must release the server-side continuation point on cancellation " + @@ -148,7 +148,7 @@ public sealed class OpcUaClientContinuationPointReleaseTests using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-cp-norelease"); var mock = new Mock(MockBehavior.Loose); - mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.SdkTelemetry.Context)); + mock.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.SdkTelemetry.Context)); mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); // BrowseAsync — return a result with a continuation point so the loop is entered. @@ -161,27 +161,27 @@ public sealed class OpcUaClientContinuationPointReleaseTests }; var browseResult = new BrowseResult { - ContinuationPoint = FakeContinuationPoint, - References = new ReferenceDescriptionCollection { firstRef }, + ContinuationPoint = FakeContinuationPoint.ToByteString(), + References = new List { firstRef }, }; mock.Setup(s => s.BrowseAsync( It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), + It.IsAny>(), It.IsAny())) .ReturnsAsync(new BrowseResponse { ResponseHeader = new ResponseHeader(), - Results = new BrowseResultCollection { browseResult }, - DiagnosticInfos = new DiagnosticInfoCollection(), + Results = new List { browseResult }, + DiagnosticInfos = new List(), }); // BrowseNextAsync — throw a non-cancellation transport error. mock.Setup(s => s.BrowseNextAsync( It.IsAny(), It.Is(release => !release), - It.IsAny(), + It.IsAny>(), It.IsAny())) .ThrowsAsync(new ServiceResultException(StatusCodes.BadCommunicationError)); @@ -195,7 +195,7 @@ public sealed class OpcUaClientContinuationPointReleaseTests mock.Verify(s => s.BrowseNextAsync( It.IsAny(), It.Is(release => release), - It.IsAny(), + It.IsAny>(), It.IsAny()), Times.Never, "A transient transport failure must not trigger a release BrowseNext call"); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientGateSplitTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientGateSplitTests.cs index b78ba971..ef5e3565 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientGateSplitTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientGateSplitTests.cs @@ -28,16 +28,16 @@ public sealed class OpcUaClientGateSplitTests { var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-o6"); var session = new Mock(MockBehavior.Loose); - session.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context)); + session.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(SdkTelemetry.Context)); session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); session.Setup(s => s.ReadAsync( It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) - .ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) => + It.IsAny>(), It.IsAny())) + .ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ArrayOf nodes, CancellationToken _) => new ReadResponse { ResponseHeader = new ResponseHeader(), - Results = [.. nodes.Select(_ => new DataValue(new Variant(42), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow))], + Results = [.. nodes.ToList().Select(_ => new DataValue(new Variant(42), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow))], DiagnosticInfos = [], }); drv.SetSessionForTest(session.Object); @@ -60,15 +60,15 @@ public sealed class OpcUaClientGateSplitTests using var _ = drv; var writeOnWire = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var releaseWrite = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - session.Setup(s => s.WriteAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(async (RequestHeader _, WriteValueCollection nodes, CancellationToken _) => + session.Setup(s => s.WriteAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns(async (RequestHeader _, ArrayOf nodes, CancellationToken _) => { writeOnWire.TrySetResult(); await releaseWrite.Task; return new WriteResponse { ResponseHeader = new ResponseHeader(), - Results = [.. nodes.Select(_ => (StatusCode)StatusCodes.Good)], + Results = [.. nodes.ToList().Select(_ => (StatusCode)StatusCodes.Good)], DiagnosticInfos = [], }; }); @@ -111,8 +111,8 @@ public sealed class OpcUaClientGateSplitTests var releaseBrowse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); session.Setup(s => s.BrowseAsync( It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) - .Returns(async (RequestHeader _, ViewDescription _, uint _, BrowseDescriptionCollection _, CancellationToken _) => + It.IsAny>(), It.IsAny())) + .Returns(async (RequestHeader _, ViewDescription _, uint _, ArrayOf _, CancellationToken _) => { browseOnWire.TrySetResult(); await releaseBrowse.Task; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientIdlePublishErrorTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientIdlePublishErrorTests.cs index e5a30ee6..4988fde8 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientIdlePublishErrorTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientIdlePublishErrorTests.cs @@ -28,7 +28,7 @@ public sealed class OpcUaClientIdlePublishErrorTests { var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, instance); var session = new Mock(MockBehavior.Loose); - session.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context)); + session.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(SdkTelemetry.Context)); session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); drv.SetSessionForTest(session.Object); drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags)); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientLifecycleGenerationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientLifecycleGenerationTests.cs index ae26ca54..12d230ca 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientLifecycleGenerationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientLifecycleGenerationTests.cs @@ -29,15 +29,15 @@ public sealed class OpcUaClientLifecycleGenerationTests private static Mock NewSession() { var mock = new Mock(MockBehavior.Loose); - mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context)); + mock.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(SdkTelemetry.Context)); mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); return mock; } - private static ReadResponse GoodRead(ReadValueIdCollection nodes) => new() + private static ReadResponse GoodRead(ArrayOf nodes) => new() { ResponseHeader = new ResponseHeader(), - Results = [.. nodes.Select(_ => new DataValue(new Variant(1), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow))], + Results = [.. nodes.ToList().Select(_ => new DataValue(new Variant(1), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow))], DiagnosticInfos = [], }; @@ -118,6 +118,26 @@ public sealed class OpcUaClientLifecycleGenerationTests drv.GetHealth().State.ShouldBe(DriverState.Unknown, "the abandoned init must not overwrite the newer health"); } + [Fact] + public async Task Cancellation_at_session_ownership_transfer_discards_session_without_failover_or_install() + { + var session = NewSession(); + using var cancellation = new CancellationTokenSource(); + using var driver = new OpcUaClientDriver(new OpcUaClientDriverOptions + { EndpointUrls = ["opc.tcp://first:4840", "opc.tcp://second:4840"] }, "sdk2-cancel-open"); + var opens = 0; + driver.OpenSessionForTest = (_, _) => + { + opens++; + cancellation.Cancel(); + return Task.FromResult(session.Object); + }; + await Should.ThrowAsync(() => driver.InitializeAsync("{}", cancellation.Token)); + opens.ShouldBe(1); + driver.Session.ShouldBeNull(); + session.Verify(s => s.Dispose(), Times.Once); + } + /// A read whose response arrives after a shutdown does not write Healthy over the shutdown. [Fact] public async Task A_read_that_outlives_a_shutdown_does_not_overwrite_the_shutdown_health() @@ -160,8 +180,8 @@ public sealed class OpcUaClientLifecycleGenerationTests var respond = new System.Collections.Concurrent.ConcurrentQueue(); session.Setup(s => s.ReadAsync( It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) - .Returns(async (RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) => + It.IsAny>(), It.IsAny())) + .Returns(async (RequestHeader _, double _, TimestampsToReturn _, ArrayOf nodes, CancellationToken _) => { var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); respond.Enqueue(gate); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientNamespaceTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientNamespaceTests.cs index 5939957c..b6c574d9 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientNamespaceTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientNamespaceTests.cs @@ -74,6 +74,35 @@ public sealed class OpcUaClientNamespaceTests NamespaceMap.TryResolve(null!, "ns=1;s=X", out _).ShouldBeFalse(); } + public static TheoryData IdentifierKinds => + [ + new NodeId(123u, 1), + new NodeId("literal;i=123", 1), + new NodeId(Guid.Parse("db8d57c4-316f-4517-819f-194c3a6db955"), 1), + new NodeId(new byte[] { 0, 1, 127, 255 }.ToByteString(), 1), + ]; + + [Theory] + [MemberData(nameof(IdentifierKinds))] + public void Stable_reference_rebinds_namespace_without_changing_identifier_type(NodeId original) + { + var stable = TestMap("http://opcfoundation.org/UA/", "urn:remote:server").ToStableReference(original); + var context = ServiceMessageContext.Create(SdkTelemetry.Context); + context.NamespaceUris.Append("urn:other"); + context.NamespaceUris.Append("urn:remote:server"); + var session = new Moq.Mock(); + session.SetupGet(s => s.MessageContext).Returns(context); + NamespaceMap.TryResolve(session.Object, stable, out var rebound).ShouldBeTrue(); + rebound.NamespaceIndex.ShouldBe((ushort)2); + rebound.IdType.ShouldBe(original.IdType); + rebound.IdentifierAsString.ShouldBe(original.IdentifierAsString); + if (original.IdType == IdType.Opaque) + { + rebound.TryGetValue(out ByteString identifier).ShouldBeTrue(); + identifier.ToArray().ShouldBe(new byte[] { 0, 1, 127, 255 }); + } + } + // ---- v3: RawPath → upstream node id resolution table ---- /// Verifies a RawPath resolves to the upstream node id authored in its TagConfig.nodeId. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSdkValueBoundaryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSdkValueBoundaryTests.cs new file mode 100644 index 00000000..79fb873e --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSdkValueBoundaryTests.cs @@ -0,0 +1,173 @@ +using Moq; +using Opc.Ua; +using Opc.Ua.Client; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; + +[Trait("Category", "Unit")] +public sealed class OpcUaClientSdkValueBoundaryTests +{ + private const string Reference = "Plant/Upstream/Value"; + private static readonly RawTagEntry[] Tags = [new(Reference, """{"nodeId":"i=2258"}""", false)]; + + private static (OpcUaClientDriver Driver, Mock Session) Connected() + { + var session = new Mock(); + session.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(SdkTelemetry.Context)); + session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); + var driver = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "sdk2-values"); + driver.SetSessionForTest(session.Object); + driver.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags)); + return (driver, session); + } + + public static TheoryData Payloads => + [ + new(Variant.From((sbyte)-3), (sbyte)-3), + new(Variant.From((byte)250), (byte)250), + new(Variant.From((short)-30), (short)-30), + new(Variant.From((ushort)60000), (ushort)60000), + new(Variant.From(-100), -100), + new(Variant.From(uint.MaxValue), uint.MaxValue), + new(Variant.From(long.MinValue), long.MinValue), + new(Variant.From(ulong.MaxValue), ulong.MaxValue), + new(Variant.From(1.5f), 1.5f), + new(Variant.From(1.5d), 1.5d), + new(Variant.From(true), true), + new(Variant.From("text"), "text"), + new(Variant.Null, null), + new(Variant.From(new DateTimeUtc(new DateTime(2026, 11, 10, 1, 2, 3, DateTimeKind.Utc))), new DateTime(2026, 11, 10, 1, 2, 3, DateTimeKind.Utc)), + new(Variant.From(new Uuid(Guid.Parse("db8d57c4-316f-4517-819f-194c3a6db955"))), Guid.Parse("db8d57c4-316f-4517-819f-194c3a6db955")), + new(Variant.From(new byte[] { 1, 2 }.ToByteString()), new byte[] { 1, 2 }), + new(Variant.From(default(ByteString)), null), + new(Variant.From(ByteString.Empty), Array.Empty()), + new(Variant.From((ArrayOf)[true, false]), new[] { true, false }), + new(Variant.From((ArrayOf)[-1, 2]), new sbyte[] { -1, 2 }), + new(Variant.From((ArrayOf)[1, 250]), new byte[] { 1, 250 }), + new(Variant.From((ArrayOf)[1, 2]), new short[] { 1, 2 }), + new(Variant.From((ArrayOf)[1, 60000]), new ushort[] { 1, 60000 }), + new(Variant.From((ArrayOf)[-1, 2]), new int[] { -1, 2 }), + new(Variant.From((ArrayOf)[long.MinValue, long.MaxValue]), new long[] { long.MinValue, long.MaxValue }), + new(Variant.From((ArrayOf)[0, ulong.MaxValue]), new ulong[] { 0, ulong.MaxValue }), + new(Variant.From((ArrayOf)[1, 2]), new double[] { 1, 2 }), + new(Variant.From((ArrayOf)[1, 2]), new uint[] { 1, 2 }), + new(Variant.From((ArrayOf)[1, 2]), new float[] { 1, 2 }), + new(Variant.From((ArrayOf)["a", "b"]), new[] { "a", "b" }), + new(Variant.From((ArrayOf)[new DateTimeUtc(new DateTime(2026, 11, 10, 1, 2, 3, DateTimeKind.Utc))]), new[] { new DateTime(2026, 11, 10, 1, 2, 3, DateTimeKind.Utc) }), + new(Variant.From((ArrayOf)[new Uuid(Guid.Empty)]), new[] { Guid.Empty }), + new(Variant.From((ArrayOf)[new byte[] { 3 }.ToByteString(), ByteString.Empty]), new byte[][] { [3], [] }), + new(Variant.From(default(ArrayOf)), null), + new(Variant.From((ArrayOf)[]), Array.Empty()), + ]; + + [Theory] + [MemberData(nameof(Payloads))] + public async Task Read_and_notification_preserve_CLR_type_status_and_timestamp_rules(Variant upstream, object? expected) + { + var (driver, session) = Connected(); + using var _ = driver; + session.Setup(s => s.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())).ReturnsAsync(new ReadResponse + { Results = [new DataValue(upstream, StatusCodes.UncertainLastUsableValue, DateTimeUtc.MinValue, DateTimeUtc.MinValue)] }); + var before = DateTime.UtcNow; + var read = (await driver.ReadAsync([Reference], TestContext.Current.CancellationToken)).Single(); + AssertPayload(read, expected, before); + DataValueSnapshot? notified = null; + driver.OnDataChange += (_, e) => notified = e.Snapshot; + var handle = driver.AddSubscriptionForTest([Reference]); + driver.DeliverNotificationForTest(handle, Reference, + new DataValue(upstream, StatusCodes.UncertainLastUsableValue, DateTimeUtc.MinValue, DateTimeUtc.MinValue)); + AssertPayload(notified.ShouldNotBeNull(), expected, before); + } + + private static void AssertPayload(DataValueSnapshot snapshot, object? expected, DateTime before) + { + snapshot.Value.ShouldBe(expected); + snapshot.Value?.GetType().ShouldBe(expected?.GetType()); + snapshot.StatusCode.ShouldBe(StatusCodes.UncertainLastUsableValue.Code); + snapshot.SourceTimestampUtc.ShouldBeNull(); + snapshot.ServerTimestampUtc.ShouldBeGreaterThanOrEqualTo(before); + snapshot.ServerTimestampUtc.Kind.ShouldBe(DateTimeKind.Utc); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public async Task Read_and_notification_detach_upstream_buffers(int shape) + { + var (driver, session) = Connected(); + using var _ = driver; + byte[] bytes = [1, 2]; + short[] numbers = [3, 4]; + // ReadOnlyMemory constructors deliberately alias the backing SDK buffer. Copying the + // test input before the adapter sees it would make the ownership assertion vacuous. + var value = shape switch + { + 0 => Variant.From(new ByteString(bytes.AsMemory())), + 1 => Variant.From(new ArrayOf(numbers.AsMemory())), + _ => Variant.From((ArrayOf)[new ByteString(bytes.AsMemory())]), + }; + object expected = shape switch { 0 => new byte[] { 1, 2 }, 1 => new short[] { 3, 4 }, _ => new byte[][] { [1, 2] } }; + session.Setup(s => s.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())).ReturnsAsync(new ReadResponse { Results = [new DataValue(value)] }); + var read = (await driver.ReadAsync([Reference], TestContext.Current.CancellationToken)).Single(); + DataValueSnapshot? notified = null; + driver.OnDataChange += (_, e) => notified = e.Snapshot; + driver.DeliverNotificationForTest(driver.AddSubscriptionForTest([Reference]), Reference, new DataValue(value)); + bytes[0] = 99; + numbers[0] = 99; + read.Value.ShouldBe(expected); + notified.ShouldNotBeNull().Value.ShouldBe(expected); + } + + [Fact] + public async Task Matrix_read_and_notification_are_refused_per_item_without_materialization() + { + var (driver, session) = Connected(); + using var _ = driver; + var sourceTime = new DateTime(2026, 11, 10, 1, 2, 3, DateTimeKind.Utc); + var serverTime = sourceTime.AddSeconds(1); + var matrix = Variant.From(new int[,] { { 1, 2 }, { 3, 4 } }.ToMatrixOf()); + session.Setup(s => s.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())).ReturnsAsync(new ReadResponse + { Results = [new DataValue(matrix, StatusCodes.Good, sourceTime, serverTime), + new DataValue(Variant.From(42), StatusCodes.Good, sourceTime, serverTime)] }); + var results = await driver.ReadAsync([Reference, Reference], TestContext.Current.CancellationToken); + results[0].Value.ShouldBeNull(); + results[0].StatusCode.ShouldBe(StatusCodes.BadTypeMismatch.Code); + results[0].SourceTimestampUtc.ShouldBe(sourceTime); + results[0].ServerTimestampUtc.ShouldBe(serverTime); + results[1].Value.ShouldBe(42); + results[1].StatusCode.ShouldBe(StatusCodes.Good.Code); + DataValueSnapshot? notified = null; + driver.OnDataChange += (_, e) => notified = e.Snapshot; + driver.DeliverNotificationForTest(driver.AddSubscriptionForTest([Reference]), Reference, + new DataValue(matrix, StatusCodes.UncertainLastUsableValue, sourceTime, serverTime)); + notified.ShouldNotBeNull().Value.ShouldBeNull(); + notified.StatusCode.ShouldBe(StatusCodes.BadTypeMismatch.Code); + notified.SourceTimestampUtc.ShouldBe(sourceTime); + notified.ServerTimestampUtc.ShouldBe(serverTime); + } + + [Fact] + public async Task Write_passes_scalar_shaped_arrays_once_and_rejects_unsupported_values_before_IO() + { + var (driver, session) = Connected(); + using var _ = driver; + short[] caller = [7, 8]; + ArrayOf sent = default; + session.Setup(s => s.WriteAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>((_, writes, _) => { sent = writes; caller[0] = 99; }) + .ReturnsAsync(new WriteResponse { Results = [StatusCodes.Good] }); + var result = await driver.WriteAsync([new(Reference, caller), new(Reference, new object()), new(Reference, new int[1, 1])], TestContext.Current.CancellationToken); + result.Select(r => r.StatusCode).ShouldBe([StatusCodes.Good.Code, StatusCodes.BadTypeMismatch.Code, StatusCodes.BadTypeMismatch.Code]); + sent.Count.ShouldBe(1); + sent[0].Value.WrappedValue.TypeInfo.ValueRank.ShouldBe(ValueRanks.OneDimension); + UpstreamValueMapping.ToClr(sent[0].Value.WrappedValue).ShouldBe(new short[] { 7, 8 }); + session.Verify(s => s.WriteAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Once); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSessionLossTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSessionLossTests.cs index 992f5462..36f2f344 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSessionLossTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSessionLossTests.cs @@ -41,7 +41,7 @@ public sealed class OpcUaClientSessionLossTests { var mock = new Mock(MockBehavior.Loose); mock.SetupGet(s => s.MessageContext) - .Returns(new ServiceMessageContext(SdkTelemetry.Context)); + .Returns(ServiceMessageContext.Create(SdkTelemetry.Context)); mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); return mock; } @@ -60,17 +60,17 @@ public sealed class OpcUaClientSessionLossTests } private static DataValue Good(object value, DateTime ts) => - new(new Variant(value), StatusCodes.Good, ts, ts); + new(VariantHelper.CastFrom(value), StatusCodes.Good, ts, ts); private static void SetupRead(Mock session, params object[] values) => session.Setup(s => s.ReadAsync( It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) - .ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) => + It.IsAny>(), It.IsAny())) + .ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ArrayOf nodes, CancellationToken _) => new ReadResponse { ResponseHeader = new ResponseHeader(), - Results = [.. nodes.Select((_, i) => new DataValue(new Variant(values[i]), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow))], + Results = [.. nodes.ToList().Select((_, i) => new DataValue(VariantHelper.CastFrom(values[i]), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow))], DiagnosticInfos = [], }); @@ -500,11 +500,11 @@ public sealed class OpcUaClientSessionLossTests private static void SetupReadAt(Mock session, object value, DateTime sourceTime) => session.Setup(s => s.ReadAsync( It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny>(), It.IsAny())) .ReturnsAsync(new ReadResponse { ResponseHeader = new ResponseHeader(), - Results = [new DataValue(new Variant(value), StatusCodes.Good, sourceTime, sourceTime)], + Results = [new DataValue(VariantHelper.CastFrom(value), StatusCodes.Good, sourceTime, sourceTime)], DiagnosticInfos = [], }); @@ -702,7 +702,7 @@ public sealed class OpcUaClientSessionLossTests var readAt = t0.AddSeconds(10); session.Setup(s => s.ReadAsync( It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny>(), It.IsAny())) .ReturnsAsync(new ReadResponse { ResponseHeader = new ResponseHeader(), diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientStaleNoSubscriptionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientStaleNoSubscriptionTests.cs index 8c7ea995..1755c1eb 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientStaleNoSubscriptionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientStaleNoSubscriptionTests.cs @@ -42,7 +42,7 @@ public sealed class OpcUaClientStaleNoSubscriptionTests { var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-763"); var session = new Mock(MockBehavior.Loose); - session.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context)); + session.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(SdkTelemetry.Context)); session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); var clock = new ManualClock(new DateTimeOffset(2026, 11, 9, 12, 0, 0, TimeSpan.Zero)); drv.Clock = clock; @@ -209,7 +209,7 @@ public sealed class OpcUaClientSlowDeleteTests RawTagEntry[] tags = [new(SpeedRef, JsonSerializer.Serialize(new { nodeId = "ns=0;s=Speed" }), WriteIdempotent: true)]; var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = tags }, "opcua-764"); var session = new Mock(MockBehavior.Loose); - session.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context)); + session.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(SdkTelemetry.Context)); session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); var clock = new ManualClock(new DateTimeOffset(2026, 11, 10, 12, 0, 0, TimeSpan.Zero)); drv.Clock = clock; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientStaleSessionRaceTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientStaleSessionRaceTests.cs index 93010f2b..9e2ece3a 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientStaleSessionRaceTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientStaleSessionRaceTests.cs @@ -46,20 +46,20 @@ public sealed class OpcUaClientStaleSessionRaceTests // MessageContext. This getter is read in the driver's synchronous prologue (before the // gate), so signalling here proves the pre-gate capture path has run. mock.SetupGet(s => s.MessageContext) - .Returns(new ServiceMessageContext(ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.SdkTelemetry.Context)) + .Returns(ServiceMessageContext.Create(ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.SdkTelemetry.Context)) .Callback(() => prologueReached?.TrySetResult()); mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); // Acknowledge issues its wire call via CallAsync; stub it so AcknowledgeAsync can run to // completion against whichever session it ends up binding to. mock.Setup(s => s.CallAsync( It.IsAny(), - It.IsAny(), + It.IsAny>(), It.IsAny())) .ReturnsAsync(new CallResponse { ResponseHeader = new ResponseHeader(), - Results = new CallMethodResultCollection(), - DiagnosticInfos = new DiagnosticInfoCollection(), + Results = new List(), + DiagnosticInfos = new List(), }); return mock; } @@ -67,10 +67,10 @@ public sealed class OpcUaClientStaleSessionRaceTests private static void VerifyAckWireHitNewSessionNotOld(Mock newSession, Mock oldSession) { newSession.Verify(s => s.CallAsync( - It.IsAny(), It.IsAny(), It.IsAny()), + It.IsAny(), It.IsAny>(), It.IsAny()), Times.Once); oldSession.Verify(s => s.CallAsync( - It.IsAny(), It.IsAny(), It.IsAny()), + It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSubscribeCleanupTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSubscribeCleanupTests.cs index b3ee7ce6..b24e111b 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSubscribeCleanupTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSubscribeCleanupTests.cs @@ -30,7 +30,7 @@ public sealed class OpcUaClientSubscribeCleanupTests { var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-o1"); var session = new Mock(MockBehavior.Loose); - session.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context)); + session.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(SdkTelemetry.Context)); session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); drv.SetSessionForTest(session.Object); drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags)); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientWriteHealthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientWriteHealthTests.cs index 320d9c30..3fcfb0ae 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientWriteHealthTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientWriteHealthTests.cs @@ -28,7 +28,7 @@ public sealed class OpcUaClientWriteHealthTests { var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-write-health"); var session = new Mock(MockBehavior.Loose); - session.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context)); + session.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(SdkTelemetry.Context)); session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); drv.SetSessionForTest(session.Object); drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags)); @@ -44,7 +44,7 @@ public sealed class OpcUaClientWriteHealthTests { var (drv, session) = NewConnectedDriver(new DriverHealth(DriverState.Degraded, Stale, "read failed")); using var _ = drv; - session.Setup(s => s.WriteAsync(It.IsAny(), It.IsAny(), It.IsAny())) + session.Setup(s => s.WriteAsync(It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync(new WriteResponse { ResponseHeader = new ResponseHeader(), @@ -54,7 +54,7 @@ public sealed class OpcUaClientWriteHealthTests var results = await drv.WriteAsync(Write(), TestContext.Current.CancellationToken); - results.Single().StatusCode.ShouldBe(StatusCodes.BadNotWritable, "the upstream refusal still passes through verbatim"); + results.Single().StatusCode.ShouldBe(StatusCodes.BadNotWritable.Code, "the upstream refusal still passes through verbatim"); var h = drv.GetHealth(); h.State.ShouldBe(DriverState.Healthy); h.LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(Stale); @@ -67,7 +67,7 @@ public sealed class OpcUaClientWriteHealthTests { var (drv, session) = NewConnectedDriver(new DriverHealth(DriverState.Healthy, Stale, null)); using var _ = drv; - session.Setup(s => s.WriteAsync(It.IsAny(), It.IsAny(), It.IsAny())) + session.Setup(s => s.WriteAsync(It.IsAny(), It.IsAny>(), It.IsAny())) .ThrowsAsync(new ServiceResultException(StatusCodes.BadConnectionClosed)); var results = await drv.WriteAsync(Write(), TestContext.Current.CancellationToken); @@ -89,8 +89,8 @@ public sealed class OpcUaClientWriteHealthTests var (drv, session) = NewConnectedDriver(healthy); using var _ = drv; using var cts = new CancellationTokenSource(); - session.Setup(s => s.WriteAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(async (RequestHeader _, WriteValueCollection _, CancellationToken ct) => + session.Setup(s => s.WriteAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns(async (RequestHeader _, ArrayOf _, CancellationToken ct) => { cts.Cancel(); await Task.Delay(Timeout.Infinite, ct); @@ -128,7 +128,7 @@ public sealed class OpcUaClientWriteHealthTests { var (drv, session) = NewConnectedDriver(new DriverHealth(DriverState.Healthy, Stale, null)); using var _ = drv; - session.Setup(s => s.WriteAsync(It.IsAny(), It.IsAny(), It.IsAny())) + session.Setup(s => s.WriteAsync(It.IsAny(), It.IsAny>(), It.IsAny())) .ThrowsAsync(new OperationCanceledException("SDK request timeout")); var results = await drv.WriteAsync(Write(), TestContext.Current.CancellationToken); @@ -149,7 +149,7 @@ public sealed class OpcUaClientWriteHealthTests using var _ = drv; drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication); drv.GetHealth().State.ShouldBe(DriverState.Degraded, "positive control: the outage is published"); - session.Setup(s => s.WriteAsync(It.IsAny(), It.IsAny(), It.IsAny())) + session.Setup(s => s.WriteAsync(It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync(new WriteResponse { ResponseHeader = new ResponseHeader(), Results = [StatusCodes.Good], DiagnosticInfos = [] }); await drv.WriteAsync(Write(), TestContext.Current.CancellationToken);