Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostDiscoveryRegistrationTests.cs
T
Joseph Doherty f127f45428
v2-ci / unit-tests (push) Has been cancelled
fix(opcua): don't register with a discovery server unless configured (#876)
OpcUaApplicationHost left ServerConfiguration.MaxRegistrationInterval at the
SDK default (30000) with a null RegistrationEndpoint, so SDK 2.0.1's
StandardServer armed its registration timer against opc.tcp://localhost:4840,
which on an OtOpcUa node is the server itself (no LDS). It answered
BadServiceUnsupported and the SDK retried forever, visible since #874.

Registration is now opt-in: ApplyDiscoveryRegistration sets
MaxRegistrationInterval = 0 (the value that keeps StandardServer from arming
the timer) unless OpcUa:DiscoveryServerUrl is set, in which case the server
registers against that URL every OpcUa:DiscoveryRegistrationIntervalMs
(default 30000) over SignAndEncrypt/Basic256Sha256, like the SDK's own
default endpoint. The validator requires an absolute opc.tcp:// URL and a
positive interval when the URL is set. Documented in Configuration.md and
OpcUaServer.md.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 06:29:35 -05:00

122 lines
5.0 KiB
C#

using System.Reflection;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// lmxopcua#876 — discovery-server (LDS) registration is opt-in. The SDK default (<c>MaxRegistrationInterval</c>
/// 30000 with a null <c>RegistrationEndpoint</c>) registers every 30 s with <c>opc.tcp://localhost:4840</c>, which on
/// an OtOpcUa node is the server itself: it answered <c>BadServiceUnsupported</c> and the SDK retried forever.
/// </summary>
public sealed class OpcUaApplicationHostDiscoveryRegistrationTests : IDisposable
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-lds-{Guid.NewGuid():N}");
public void Dispose()
{
try { if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, true); }
catch { /* best effort */ }
}
[Fact]
public void Default_options_disable_registration()
{
var config = new ServerConfiguration();
config.MaxRegistrationInterval.ShouldBeGreaterThan(0, "precondition: the SDK default registers");
OpcUaApplicationHost.ApplyDiscoveryRegistration(config, new OpcUaApplicationHostOptions());
config.MaxRegistrationInterval.ShouldBe(0);
config.RegistrationEndpoint.ShouldBeNull();
}
[Fact]
public void Configured_discovery_server_is_registered_with_at_the_configured_interval()
{
var config = new ServerConfiguration();
OpcUaApplicationHost.ApplyDiscoveryRegistration(config, new OpcUaApplicationHostOptions
{
DiscoveryServerUrl = " opc.tcp://lds-host:4840 ", DiscoveryRegistrationIntervalMs = 45_000,
});
config.MaxRegistrationInterval.ShouldBe(45_000);
config.RegistrationEndpoint.ShouldNotBeNull();
config.RegistrationEndpoint!.EndpointUrl.ShouldBe("opc.tcp://lds-host:4840");
config.RegistrationEndpoint.Server.ApplicationType.ShouldBe(ApplicationType.DiscoveryServer);
}
/// <summary>The real build path: a host started with default options builds a non-registering configuration and
/// the started SDK server never arms its registration timer.</summary>
[Fact]
public async Task Started_host_with_default_options_does_not_register()
{
var (host, server) = await StartAsync(o => { });
try
{
var serverConfig = host.ApplicationInstance!.ApplicationConfiguration!.ServerConfiguration!;
serverConfig.MaxRegistrationInterval.ShouldBe(0);
serverConfig.RegistrationEndpoint.ShouldBeNull();
RegistrationTimer(server).ShouldBeNull("the SDK must not arm its RegisterServer2 timer");
}
finally
{
await host.DisposeAsync();
}
}
[Fact]
public async Task Started_host_with_a_discovery_server_registers_with_it()
{
var ldsUrl = $"opc.tcp://127.0.0.1:{OpcUaHostStart.AllocateFreePort()}";
var (host, server) = await StartAsync(o =>
{
o.DiscoveryServerUrl = ldsUrl;
o.DiscoveryRegistrationIntervalMs = 60_000;
});
try
{
var serverConfig = host.ApplicationInstance!.ApplicationConfiguration!.ServerConfiguration!;
serverConfig.MaxRegistrationInterval.ShouldBe(60_000);
serverConfig.RegistrationEndpoint!.EndpointUrl.ShouldBe(ldsUrl);
RegistrationTimer(server).ShouldNotBeNull("positive control: a configured discovery server arms the timer");
}
finally
{
await host.DisposeAsync();
}
}
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> StartAsync(Action<OpcUaApplicationHostOptions> configure) =>
await OpcUaHostStart.StartAsync(
port =>
{
var options = new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.DiscoveryRegistrationTest",
ApplicationUri = $"urn:OtOpcUa.DiscoveryRegistrationTest:{Guid.NewGuid():N}",
OpcUaPort = port,
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
};
configure(options);
return new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
},
() => new OtOpcUaSdkServer(), Ct);
/// <summary>The SDK 2.0.1 <c>StandardServer.m_registrationTimer</c>, created only when
/// <c>MaxRegistrationInterval &gt; 0</c>.</summary>
private static object? RegistrationTimer(StandardServer server)
{
var field = typeof(StandardServer).GetField("m_registrationTimer", BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("StandardServer.m_registrationTimer not found — SDK internals changed; re-check #876.");
return field.GetValue(server);
}
}