/* ======================================================================== * Copyright (c) 2005-2025 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/ * ======================================================================*/ #pragma warning disable CA2007 using System; using System.Net; using System.Threading.Tasks; using Crdt; using Crdt.Transport; using Microsoft.Extensions.DependencyInjection; using Moq; using NUnit.Framework; namespace Opc.Ua.Redundancy.Server.Tests { /// /// Unit tests for the replicated gossip options (transport selection, /// peers, and decoding limits). /// [TestFixture] [Category("Distributed")] public sealed class ReplicatedOptionsTests { [Test] public void DefaultsAreSet() { var options = new ReplicatedAddressSpaceOptions(); Assert.That(options.ReplicaId, Is.Not.EqualTo(ReplicaId.Empty)); Assert.That(options.TimeProvider, Is.SameAs(TimeProvider.System)); Assert.That(options.TransportFactory, Is.Null); Assert.That(options.MaxEntryCount, Is.GreaterThan(0)); Assert.That(options.MaxPayloadBytes, Is.GreaterThan(0)); } [Test] public void SessionOptionsExposeFastReconnectDefault() { var options = new ReplicatedSessionOptions(); Assert.That(options.Session, Is.Not.Null); Assert.That(options.Session.EnableFastReconnect, Is.False); } [Test] public void CreateReaderOptionsHonorsLimits() { var options = new ReplicatedAddressSpaceOptions { MaxEntryCount = 5, MaxPayloadBytes = 99 }; CrdtReaderOptions reader = options.CreateReaderOptions(); Assert.That(reader.MaxCollectionCount, Is.EqualTo(5)); Assert.That(reader.MaxStringBytes, Is.EqualTo(99)); } [Test] public async Task UseTcpGossipConfiguresTransportFactoryAsync() { var options = new ReplicatedAddressSpaceOptions(); options.AddPeer(new IPEndPoint(IPAddress.Loopback, 4999)); options.AllowUnauthenticatedGossip = true; options.UseTcpGossip(IPAddress.Loopback, 0, TimeSpan.FromMilliseconds(50)); Assert.That(options.TransportFactory, Is.Not.Null); ITransport transport = options.CreateTransport(EmptyServices(), out InMemoryNetwork? network); try { Assert.That(transport, Is.InstanceOf()); Assert.That(network, Is.Null); } finally { await transport.DisposeAsync().ConfigureAwait(false); if (network != null) { await network.DisposeAsync().ConfigureAwait(false); } } } [Test] public async Task UseUdpGossipConfiguresTransportFactoryAsync() { var options = new ReplicatedSessionOptions(); options.AddPeer(new IPEndPoint(IPAddress.Loopback, 4998)); options.AllowUnauthenticatedGossip = true; options.UseUdpGossip(IPAddress.Loopback, 0); Assert.That(options.TransportFactory, Is.Not.Null); ITransport transport = options.CreateTransport(EmptyServices(), out InMemoryNetwork? network); try { Assert.That(transport, Is.InstanceOf()); Assert.That(network, Is.Null); } finally { await transport.DisposeAsync().ConfigureAwait(false); if (network != null) { await network.DisposeAsync().ConfigureAwait(false); } } } [Test] public async Task CreateTransportDefaultsToInProcessNetworkAsync() { var options = new ReplicatedAddressSpaceOptions(); ITransport transport = options.CreateTransport(EmptyServices(), out InMemoryNetwork? network); try { Assert.That(transport, Is.InstanceOf()); Assert.That(network, Is.Not.Null); } finally { await transport.DisposeAsync().ConfigureAwait(false); if (network != null) { await network.DisposeAsync().ConfigureAwait(false); } } } [Test] public void UnauthenticatedTcpGossipFailsClosedByDefault() { var options = new ReplicatedAddressSpaceOptions(); options.UseTcpGossip(IPAddress.Loopback, 0); Assert.That( () => options.CreateTransport(EmptyServices(), out _), Throws.InvalidOperationException .With.Message.Contains(nameof(ReplicatedGossipOptions.AllowUnauthenticatedGossip))); } [Test] public void UnauthenticatedUdpGossipFailsClosedByDefault() { var options = new ReplicatedAddressSpaceOptions(); options.UseUdpGossip(IPAddress.Loopback, 0); Assert.That( () => options.CreateTransport(EmptyServices(), out _), Throws.InvalidOperationException .With.Message.Contains(nameof(ReplicatedGossipOptions.AllowUnauthenticatedGossip))); } [Test] public void AddPeerRejectsNull() { var options = new ReplicatedAddressSpaceOptions(); Assert.That(() => options.AddPeer(null!), Throws.ArgumentNullException); } [Test] public void UseTcpGossipRejectsNullAddress() { var options = new ReplicatedAddressSpaceOptions(); Assert.That(() => options.UseTcpGossip(null!, 0), Throws.ArgumentNullException); } private static IServiceProvider EmptyServices() { return Mock.Of(); } [Test] public async Task CreateTransportRegistersTcpPeerSinkAsync() { var options = new ReplicatedAddressSpaceOptions { AllowUnauthenticatedGossip = true }; options.UseTcpGossip(IPAddress.Loopback, 0); var registry = new GossipPeerRegistry(); await using ServiceProvider services = new ServiceCollection() .AddSingleton(registry) .BuildServiceProvider(); ITransport transport = options.CreateTransport(services, out InMemoryNetwork? network); try { Assert.That(transport, Is.InstanceOf()); // Offset 0 (address-space fabric): the sink callback forwards the endpoint unchanged. registry.AddPeer(new IPEndPoint(IPAddress.Loopback, 4999)); } finally { await transport.DisposeAsync().ConfigureAwait(false); if (network != null) { await network.DisposeAsync().ConfigureAwait(false); } } } [Test] public async Task CreateTransportRegistersUdpPeerSinkWithOffsetAsync() { var options = new ReplicatedSessionOptions { AllowUnauthenticatedGossip = true, GossipPeerPortOffset = 1 }; options.UseUdpGossip(IPAddress.Loopback, 0); var registry = new GossipPeerRegistry(); await using ServiceProvider services = new ServiceCollection() .AddSingleton(registry) .BuildServiceProvider(); ITransport transport = options.CreateTransport(services, out InMemoryNetwork? network); try { Assert.That(transport, Is.InstanceOf()); // Offset 1 (session fabric): the sink callback forwards the endpoint at port + 1. registry.AddPeer(new IPEndPoint(IPAddress.Loopback, 4999)); } finally { await transport.DisposeAsync().ConfigureAwait(false); if (network != null) { await network.DisposeAsync().ConfigureAwait(false); } } } } }