/* ======================================================================== * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; using NUnit.Framework; using Opc.Ua.PubSub.Application; using Opc.Ua.PubSub.Configuration; using Opc.Ua.PubSub.DataSets; using Opc.Ua.PubSub.Encoding; using Opc.Ua.PubSub.MetaData; using Opc.Ua.PubSub.Security; using Opc.Ua.PubSub.Security.Policies; using Opc.Ua.PubSub.Transports; using Opc.Ua.Tests; namespace Opc.Ua.PubSub.Tests.Application { /// /// Verifies that a Subscriber uses the DataSetMetaData configured on its /// DataSetReader to decode RawData DataSetMessages when it runs separately /// from the Publisher and shares no metadata registry with it, per /// /// Part 14 ยง6.2.9.4. /// [TestFixture] [TestSpec("6.2.9.4")] [CancelAfter(30000)] public sealed class SubscriberDataSetMetaDataTests { private const string Address = "opc.udp://239.0.0.1:4840"; private const string SecurityGroupId = "SubscriberMetaDataGroup"; private const string DataSetName = "Simple"; private const string ReaderName = "Reader 1"; private const ushort PublisherIdValue = 1; private const ushort WriterGroupId = 100; private const ushort DataSetWriterId = 1; [TestCase(MessageSecurityMode.SignAndEncrypt)] [TestCase(MessageSecurityMode.None)] public async Task SeparateSubscriberDecodesRawDataWithConfiguredMetaDataAsync( MessageSecurityMode securityMode) { var bus = new InMemoryBus(); var sink = new FirstDataSetSink(); await using IPubSubApplication subscriber = CreateBuilder(bus, securityMode) .WithApplicationId("urn:test:subscriber") .UseConfiguration(CreateSubscriberConfiguration( securityMode, PublisherIdValue, WriterGroupId)) .AddSubscribedDataSetSink(ReaderName, sink) .Build(); await using IPubSubApplication publisher = CreateBuilder(bus, securityMode) .WithApplicationId("urn:test:publisher") .UseConfiguration(CreatePublisherConfiguration(securityMode)) .AddDataSetSource(DataSetName, new CounterSource()) .Build(); await subscriber.StartAsync().ConfigureAwait(false); await publisher.StartAsync().ConfigureAwait(false); IReadOnlyList fields = await sink.FirstDataSet .WaitAsync(TimeSpan.FromSeconds(10)) .ConfigureAwait(false); Assert.That(fields, Has.Count.EqualTo(2)); Assert.Multiple(() => { Assert.That(fields[0].Name, Is.EqualTo("Counter")); Assert.That(fields[0].Value.TryGetValue(out int counter), Is.True); Assert.That(counter, Is.GreaterThan(0)); Assert.That(fields[1].Name, Is.EqualTo("Toggle")); }); } [Test] public async Task BuildRegistersMetaDataOfReaderWithExactFilterAsync() { await using IPubSubApplication subscriber = CreateBuilder( new InMemoryBus(), MessageSecurityMode.None) .WithApplicationId("urn:test:subscriber") .UseConfiguration(CreateSubscriberConfiguration( MessageSecurityMode.None, PublisherIdValue, WriterGroupId)) .Build(); MetaDataMatchResult result = subscriber.MetaDataRegistry.TryGet( ReaderKey(WriterGroupId), out DataSetMetaDataType? metaData); Assert.That(result, Is.EqualTo(MetaDataMatchResult.Match)); Assert.That(metaData!.Fields.Count, Is.EqualTo(2)); } [Test] public async Task BuildDoesNotRegisterMetaDataOfReaderWithWildcardFilterAsync() { await using IPubSubApplication subscriber = CreateBuilder( new InMemoryBus(), MessageSecurityMode.None) .WithApplicationId("urn:test:subscriber") .UseConfiguration(CreateSubscriberConfiguration( MessageSecurityMode.None, PublisherIdValue, writerGroupId: 0)) .Build(); Assert.That(subscriber.MetaDataRegistry.Keys.Count, Is.Zero); } [Test] public async Task ReaderMetaDataDoesNotReplacePublishedDataSetMetaDataAsync() { PubSubConfigurationDataType configuration = CreatePublisherConfiguration(MessageSecurityMode.None); PubSubConfigurationDataType readers = CreateSubscriberConfiguration( MessageSecurityMode.None, PublisherIdValue, WriterGroupId); configuration.Connections[0].ReaderGroups = readers.Connections[0].ReaderGroups; configuration.Connections[0].ReaderGroups[0].DataSetReaders[0] .DataSetMetaData.Name = "ReaderView"; await using IPubSubApplication application = CreateBuilder( new InMemoryBus(), MessageSecurityMode.None) .WithApplicationId("urn:test:loopback") .UseConfiguration(configuration) .AddDataSetSource(DataSetName, new CounterSource()) .Build(); application.MetaDataRegistry.TryGet( ReaderKey(WriterGroupId), out DataSetMetaDataType? metaData); Assert.That(metaData!.Name, Is.EqualTo(DataSetName)); } private static DataSetMetaDataKey ReaderKey(ushort writerGroupId) { return new DataSetMetaDataKey( PublisherId.FromUInt16(PublisherIdValue), writerGroupId, DataSetWriterId, Uuid.Empty, 1); } private static PubSubApplicationBuilder CreateBuilder( InMemoryBus bus, MessageSecurityMode securityMode) { PubSubApplicationBuilder builder = new PubSubApplicationBuilder( NUnitTelemetryContext.Create()) .UseAllStandardEncoders() .AddTransportFactory(new InMemoryBusTransportFactory(bus)); if (securityMode != MessageSecurityMode.None) { builder.AddSecurityKeyProvider(CreateKeyProvider()); } return builder; } private static PubSubConfigurationDataType CreatePublisherConfiguration( MessageSecurityMode securityMode) { return PubSubConfigurationBuilder.Create() .AddPublishedDataSet(DataSetName, dataSet => dataSet .AddField("Counter", (byte)BuiltInType.Int32, DataTypeIds.Int32) .AddField("Toggle", (byte)BuiltInType.Boolean, DataTypeIds.Boolean)) .AddConnection("Publisher Connection", connection => connection .WithPublisherId(new Variant(PublisherIdValue)) .WithTransportProfile(Profiles.PubSubUdpUadpTransport) .WithAddress(Address) .AddWriterGroup("WriterGroup 1", group => { group .WithWriterGroupId(WriterGroupId) .WithPublishingInterval(50) .WithMessageSettings(new UadpWriterGroupMessageDataType { DataSetOrdering = DataSetOrderingType.AscendingWriterId, NetworkMessageContentMask = (uint)NetworkMessageContentMask }) .WithTransportSettings(new DatagramWriterGroupTransportDataType()); if (securityMode != MessageSecurityMode.None) { group.WithSecurity( securityMode, SecurityGroupId, "opc.tcp://localhost:4840/SecurityKeyService"); } group.AddDataSetWriter("Writer 1", writer => writer .WithDataSetWriterId(DataSetWriterId) .WithDataSetName(DataSetName) .WithKeyFrameCount(1) .WithFieldContentMask(DataSetFieldContentMask.RawData) .WithMessageSettings(new UadpDataSetWriterMessageDataType { DataSetMessageContentMask = (uint)DataSetMessageContentMask })); })) .Build(); } private static PubSubConfigurationDataType CreateSubscriberConfiguration( MessageSecurityMode securityMode, ushort publisherId, ushort writerGroupId) { return PubSubConfigurationBuilder.Create() .AddConnection("Subscriber Connection", connection => connection .WithPublisherId(new Variant(publisherId)) .WithTransportProfile(Profiles.PubSubUdpUadpTransport) .WithAddress(Address) .AddReaderGroup("ReaderGroup 1", group => { if (securityMode != MessageSecurityMode.None) { group.WithSecurity( securityMode, SecurityGroupId, "opc.tcp://localhost:4840/SecurityKeyService"); } group.AddDataSetReader(ReaderName, reader => reader .WithFilter(new Variant(publisherId), writerGroupId, DataSetWriterId) .WithFieldContentMask(DataSetFieldContentMask.RawData) .WithMessageReceiveTimeout(5000) .WithMirrorSubscribedDataSet(ReaderName) .WithMessageSettings(new UadpDataSetReaderMessageDataType { NetworkMessageContentMask = (uint)NetworkMessageContentMask, DataSetMessageContentMask = (uint)DataSetMessageContentMask }) .WithDataSetMetaData(DataSetName, metaData => metaData .WithoutFieldIds() .AddField("Counter", (byte)BuiltInType.Int32, DataTypeIds.Int32) .AddField("Toggle", (byte)BuiltInType.Boolean, DataTypeIds.Boolean))); })) .Build(); } private static UadpNetworkMessageContentMask NetworkMessageContentMask => UadpNetworkMessageContentMask.PublisherId | UadpNetworkMessageContentMask.GroupHeader | UadpNetworkMessageContentMask.WriterGroupId | UadpNetworkMessageContentMask.PayloadHeader | UadpNetworkMessageContentMask.NetworkMessageNumber | UadpNetworkMessageContentMask.SequenceNumber; // No MajorVersion or MinorVersion: the Subscriber cannot compare versions. private static UadpDataSetMessageContentMask DataSetMessageContentMask => UadpDataSetMessageContentMask.Status | UadpDataSetMessageContentMask.SequenceNumber; private static StaticSecurityKeyProvider CreateKeyProvider() { PubSubAes256CtrPolicy policy = PubSubAes256CtrPolicy.Instance; byte[] signing = new byte[policy.SigningKeyLength]; byte[] encrypting = new byte[policy.EncryptingKeyLength]; byte[] nonce = new byte[AesCtrNonceLayout.KeyNonceLength]; for (int i = 0; i < signing.Length; i++) { signing[i] = (byte)(i + 1); } for (int i = 0; i < encrypting.Length; i++) { encrypting[i] = (byte)(i + 100); } for (int i = 0; i < nonce.Length; i++) { nonce[i] = (byte)(i + 200); } var key = new PubSubSecurityKey( 1U, ByteString.Create(signing), ByteString.Create(encrypting), ByteString.Create(nonce), DateTimeUtc.From(DateTime.UtcNow), TimeSpan.FromMinutes(60)); var ring = new PubSubSecurityKeyRing(SecurityGroupId); ring.SetCurrent(key); return new StaticSecurityKeyProvider(SecurityGroupId, ring); } /// /// Produces an increasing counter and a toggle for each sample. /// private sealed class CounterSource : IPublishedDataSetSource { public DataSetMetaDataType BuildMetaData() { return new DataSetMetaDataType { Name = DataSetName, DataSetClassId = Uuid.Empty, Fields = [ new FieldMetaData { Name = "Counter", BuiltInType = (byte)BuiltInType.Int32, DataType = DataTypeIds.Int32, ValueRank = ValueRanks.Scalar }, new FieldMetaData { Name = "Toggle", BuiltInType = (byte)BuiltInType.Boolean, DataType = DataTypeIds.Boolean, ValueRank = ValueRanks.Scalar } ], ConfigurationVersion = new ConfigurationVersionDataType { MajorVersion = 1, MinorVersion = 0 } }; } public ValueTask SampleAsync( DataSetMetaDataType metaData, CancellationToken cancellationToken = default) { int counter = Interlocked.Increment(ref m_counter); ArrayOf fields = [ new DataSetField { Name = "Counter", Value = new Variant(counter) }, new DataSetField { Name = "Toggle", Value = new Variant((counter & 1) == 0) } ]; return new ValueTask( new PublishedDataSetSnapshot( metaData.ConfigurationVersion ?? new ConfigurationVersionDataType(), fields, DateTimeUtc.From(DateTime.UtcNow))); } private int m_counter; } /// /// Completes with the fields of the first received DataSet. /// private sealed class FirstDataSetSink : ISubscribedDataSetSink { public Task> FirstDataSet => m_first.Task; public ValueTask WriteAsync( IReadOnlyList fields, CancellationToken cancellationToken = default) { m_first.TrySetResult(fields); return default; } private readonly TaskCompletionSource> m_first = new(TaskCreationOptions.RunContinuationsAsynchronously); } /// /// Delivers every frame sent by any transport of the bus to every /// transport of the bus, like a datagram group on one host. /// private sealed class InMemoryBus { public void Attach(Channel receiver) { lock (m_lock) { m_receivers.Add(receiver); } } public void Detach(Channel receiver) { lock (m_lock) { m_receivers.Remove(receiver); } } public void Send(ReadOnlyMemory payload) { Channel[] receivers; lock (m_lock) { receivers = [.. m_receivers]; } foreach (Channel receiver in receivers) { receiver.Writer.TryWrite(payload.ToArray()); } } private readonly Lock m_lock = new(); private readonly List> m_receivers = []; } private sealed class InMemoryBusTransportFactory : IPubSubTransportFactory { public InMemoryBusTransportFactory(InMemoryBus bus) { m_bus = bus; } public string TransportProfileUri => Profiles.PubSubUdpUadpTransport; public IPubSubTransport Create( PubSubConnectionDataType connection, ITelemetryContext telemetry, TimeProvider timeProvider) { return new InMemoryBusTransport(m_bus); } private readonly InMemoryBus m_bus; } private sealed class InMemoryBusTransport : IPubSubTransport { public InMemoryBusTransport(InMemoryBus bus) { m_bus = bus; } public string TransportProfileUri => Profiles.PubSubUdpUadpTransport; public PubSubTransportDirection Direction => PubSubTransportDirection.SendReceive; public bool IsConnected { get; private set; } public event EventHandler? StateChanged { add { } remove { } } public ValueTask OpenAsync(CancellationToken cancellationToken = default) { m_bus.Attach(m_received); IsConnected = true; return default; } public ValueTask CloseAsync(CancellationToken cancellationToken = default) { m_bus.Detach(m_received); IsConnected = false; return default; } public ValueTask SendAsync( ReadOnlyMemory payload, string? topic = null, CancellationToken cancellationToken = default) { m_bus.Send(payload); return default; } public async IAsyncEnumerable ReceiveAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default) { while (await m_received.Reader.WaitToReadAsync(cancellationToken) .ConfigureAwait(false)) { while (m_received.Reader.TryRead(out byte[]? frame)) { yield return new PubSubTransportFrame( frame, null, DateTimeUtc.From(DateTime.UtcNow)); } } } public ValueTask DisposeAsync() { m_bus.Detach(m_received); m_received.Writer.TryComplete(); IsConnected = false; return default; } private readonly InMemoryBus m_bus; private readonly Channel m_received = Channel.CreateUnbounded(); } } }