Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SecurityProfilesReplaceWireTests.cs
T
Joseph Doherty a83bf1fa27
v2-ci / unit-tests (push) Has been cancelled
fix(opcua): a configured OpcUa:EnabledSecurityProfiles list replaces the default, so None can be dropped (#879)
ConfigurationBinder appends configured collection items to an existing list, so
the pre-filled [None, Sign, SignAndEncrypt] default made "drop None" a no-op:
every deployment that listed only the secure profiles still advertised an
unsigned/unencrypted None endpoint.

EnabledSecurityProfiles is now null by default; ResolveEnabledSecurityProfiles()
applies the unchanged three-profile default only when nothing is configured.
A set-but-empty list fails startup validation and BuildSecurityPolicies no
longer falls back to a None endpoint on empty input (it throws). Sweep of all
options classes found no other non-empty pre-filled collection default.

Tests: options binding (indexed, JSON, env forms, default, empty, bad name), a
booted-server wire test (no None endpoint, None connection refused with
BadSecurityPolicyRejected, secure connection works; default case is the control).

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-12 11:38:42 -05:00

154 lines
7.7 KiB
C#

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// lmxopcua#879 over the wire. The options come from a real <c>ConfigurationBinder</c> bind (the path the Host
/// uses), so a regression to a pre-filled list shows up as an advertised <c>None</c> endpoint that admits a
/// session. The default case is the positive control: the same probes must find and use <c>None</c> there.
/// </summary>
public sealed class SecurityProfilesReplaceWireTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
[Fact]
public async Task Configuring_only_the_secure_profiles_removes_the_None_endpoint_and_refuses_a_None_connection()
{
await using var rig = await Rig.StartAsync(new Dictionary<string, string?>
{
["OpcUa:EnabledSecurityProfiles:0"] = "Basic256Sha256Sign",
["OpcUa:EnabledSecurityProfiles:1"] = "Basic256Sha256SignAndEncrypt",
}, Ct);
var modes = (await rig.GetEndpointModesAsync(Ct)).Distinct().Order().ToArray();
modes.ShouldBe([MessageSecurityMode.Sign, MessageSecurityMode.SignAndEncrypt]);
// Positive control: the secure endpoint really is usable, so the refusal below is about None, not about the rig.
using (var secure = await rig.ConnectAsync(MessageSecurityMode.SignAndEncrypt, Ct))
secure.Connected.ShouldBeTrue();
var refusal = await Should.ThrowAsync<ServiceResultException>(() => rig.ConnectAsync(MessageSecurityMode.None, Ct));
refusal.StatusCode.ShouldBe(StatusCodes.BadSecurityPolicyRejected);
}
[Fact]
public async Task Nothing_configured_still_advertises_all_three_and_a_None_connection_is_admitted()
{
await using var rig = await Rig.StartAsync(new Dictionary<string, string?>(), Ct);
(await rig.GetEndpointModesAsync(Ct)).Distinct().Order().ToArray()
.ShouldBe([MessageSecurityMode.None, MessageSecurityMode.Sign, MessageSecurityMode.SignAndEncrypt]);
using var none = await rig.ConnectAsync(MessageSecurityMode.None, Ct);
none.Connected.ShouldBeTrue();
}
private sealed class Rig : IAsyncDisposable
{
private readonly string _pki = TestClientSecurity.AllocatePkiRoot();
private readonly string _clientPki = TestClientSecurity.AllocatePkiRoot();
private OpcUaApplicationHost _host = null!;
private int _port;
public static async Task<Rig> StartAsync(Dictionary<string, string?> values, CancellationToken ct)
{
var rig = new Rig();
var section = new ConfigurationBuilder().AddInMemoryCollection(values).Build();
try
{
(rig._host, _) = await OpcUaHostStart.StartAsync(port =>
{
var options = new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.Sec879",
ApplicationUri = $"urn:OtOpcUa.Sec879:{Guid.NewGuid():N}",
PublicHostname = "127.0.0.1",
PkiStoreRoot = rig._pki,
AutoAcceptUntrustedClientCertificates = true,
};
section.GetSection("OpcUa").Bind(options); // the real binder, exactly what AddValidatedOptions does
options.OpcUaPort = port;
rig._port = port;
return new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
}, () => new OtOpcUaSdkServer(), ct);
return rig;
}
catch { await rig.DisposeAsync(); throw; }
}
private string Url => $"opc.tcp://127.0.0.1:{_port}/OtOpcUa";
private (ApplicationConfiguration Config, ApplicationInstance App)? _client;
private async Task<(ApplicationConfiguration Config, ApplicationInstance App)> ClientAsync(CancellationToken ct)
{
if (_client is { } cached) return cached;
var security = TestClientSecurity.Build(_clientPki);
security.ApplicationCertificate!.SubjectName = "CN=OtOpcUa.Sec879Client";
var telemetry = DefaultTelemetry.Create(_ => { });
var config = new ApplicationConfiguration(telemetry)
{
ApplicationName = "OtOpcUa.Sec879Client",
ApplicationUri = $"urn:OtOpcUa.Sec879Client.{Guid.NewGuid():N}",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = security,
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
};
await config.ValidateAsync(ApplicationType.Client, ct);
var app = new ApplicationInstance(telemetry) { ApplicationName = config.ApplicationName, ApplicationType = ApplicationType.Client };
app.ApplicationConfiguration = config;
await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct);
_client = (config, app);
return (config, app);
}
public async Task<MessageSecurityMode[]> GetEndpointModesAsync(CancellationToken ct)
{
var (config, _) = await ClientAsync(ct);
using var discovery = await DiscoveryClient.CreateAsync(config, new Uri(Url), DiagnosticsMasks.None, ct);
var endpoints = await discovery.GetEndpointsAsync(default, ct);
return endpoints.ToArray()!.Select(e => e.SecurityMode).ToArray();
}
/// <summary>
/// Opens an anonymous session on the endpoint of the requested mode. For <c>None</c> the endpoint
/// description is synthesised rather than discovered, so a server that no longer advertises it is
/// probed with exactly what a downgrading client would send.
/// </summary>
public async Task<ISession> ConnectAsync(MessageSecurityMode mode, CancellationToken ct)
{
var (config, _) = await ClientAsync(ct);
var telemetry = DefaultTelemetry.Create(_ => { });
// Always start from a discovered secure endpoint (its token policies are what the server really sent);
// None is a clone of it with the mode and policy downgraded, i.e. what a downgrading client sends.
var description = await CoreClientUtils.SelectEndpointAsync(config, Url, true, telemetry, ct)
?? throw new InvalidOperationException("No secure endpoint.");
if (mode == MessageSecurityMode.None)
{
description = (EndpointDescription)description.Clone();
description.SecurityMode = MessageSecurityMode.None;
description.SecurityPolicyUri = SecurityPolicies.None;
description.ServerCertificate = default;
}
else description.SecurityMode.ShouldBe(mode);
return await new DefaultSessionFactory(telemetry).CreateAsync(
config, new ConfiguredEndpoint(null, description, EndpointConfiguration.Create(config)),
updateBeforeConnect: false, checkDomain: false, sessionName: "Sec879", sessionTimeout: 60_000,
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: default, ct: ct);
}
public async ValueTask DisposeAsync()
{
if (_client is { } client) await client.App.DisposeAsync();
if (_host is not null) await _host.DisposeAsync();
foreach (var dir in new[] { _pki, _clientPki })
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}
}
}