Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/OpcUaSecurityProfilesBindingTests.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

157 lines
7.0 KiB
C#

using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// lmxopcua#879 — <c>ConfigurationBinder</c> appends configured collection items to a pre-filled list, so
/// <c>OpcUa:EnabledSecurityProfiles</c> could never drop <c>None</c>. These tests pin "the configured list REPLACES
/// the default" through the same <c>AddValidatedOptions</c> wiring <c>Program.cs</c> uses, for the indexed
/// (env-var / in-memory) form and the JSON array form, plus the unchanged default and the empty-list rule.
/// </summary>
public sealed class OpcUaSecurityProfilesBindingTests
{
private static readonly OpcUaSecurityProfile[] AllThree =
[
OpcUaSecurityProfile.None,
OpcUaSecurityProfile.Basic256Sha256Sign,
OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt,
];
private static IOptions<OpcUaApplicationHostOptions> Resolve(IConfiguration configuration, out ServiceProvider provider)
{
var services = new ServiceCollection();
services.AddSingleton(configuration);
services.AddValidatedOptions<OpcUaApplicationHostOptions, OpcUaApplicationHostOptionsValidator>(configuration, "OpcUa");
provider = services.BuildServiceProvider();
return provider.GetRequiredService<IOptions<OpcUaApplicationHostOptions>>();
}
/// <summary>The #879 repro: only the secure profiles configured gives exactly those, no None.</summary>
[Fact]
public void Configured_secure_profiles_replace_the_default_and_drop_None()
{
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["OpcUa:EnabledSecurityProfiles:0"] = "Basic256Sha256SignAndEncrypt",
["OpcUa:EnabledSecurityProfiles:1"] = "Basic256Sha256Sign",
}).Build();
var options = Resolve(configuration, out var provider);
using var sp = provider;
options.Value.ResolveEnabledSecurityProfiles().ShouldBe(
[OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt, OpcUaSecurityProfile.Basic256Sha256Sign]);
options.Value.ResolveEnabledSecurityProfiles().ShouldNotContain(OpcUaSecurityProfile.None);
}
/// <summary>The JSON array form (appsettings.json, as docs/security.md shows) behaves the same as the indexed form.</summary>
[Fact]
public void Json_array_form_replaces_the_default_too()
{
var json = """{ "OpcUa": { "EnabledSecurityProfiles": [ "Basic256Sha256Sign", "Basic256Sha256SignAndEncrypt" ] } }""";
var configuration = new ConfigurationBuilder()
.AddJsonStream(new MemoryStream(Encoding.UTF8.GetBytes(json))).Build();
var options = Resolve(configuration, out var provider);
using var sp = provider;
options.Value.ResolveEnabledSecurityProfiles().ShouldBe(
[OpcUaSecurityProfile.Basic256Sha256Sign, OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt]);
}
/// <summary>The environment-variable form (<c>OpcUa__EnabledSecurityProfiles__0</c>) used by the Windows service gate.</summary>
[Fact]
public void Environment_variable_form_replaces_the_default()
{
const string prefix = "OTOPCUA_879_TEST_";
Environment.SetEnvironmentVariable(prefix + "OpcUa__EnabledSecurityProfiles__0", "Basic256Sha256SignAndEncrypt");
try
{
var configuration = new ConfigurationBuilder().AddEnvironmentVariables(prefix).Build();
var options = Resolve(configuration, out var provider);
using var sp = provider;
options.Value.ResolveEnabledSecurityProfiles().ShouldBe([OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt]);
}
finally { Environment.SetEnvironmentVariable(prefix + "OpcUa__EnabledSecurityProfiles__0", null); }
}
/// <summary>Configuring None explicitly alongside the secure profiles still works (an operator may want it).</summary>
[Fact]
public void Explicit_None_is_honoured_when_listed()
{
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["OpcUa:EnabledSecurityProfiles:0"] = "None",
}).Build();
var options = Resolve(configuration, out var provider);
using var sp = provider;
options.Value.ResolveEnabledSecurityProfiles().ShouldBe([OpcUaSecurityProfile.None]);
}
/// <summary>Nothing configured: the three-profile default is applied, unchanged from before the fix.</summary>
[Fact]
public void Nothing_configured_keeps_the_three_profile_default()
{
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["OpcUa:OpcUaPort"] = "4841", // a section exists, the list key does not
}).Build();
var options = Resolve(configuration, out var provider);
using var sp = provider;
options.Value.ResolveEnabledSecurityProfiles().ShouldBe(AllThree);
new OpcUaApplicationHostOptions().ResolveEnabledSecurityProfiles().ShouldBe(AllThree);
}
/// <summary>An empty JSON array binds to nothing, i.e. "not configured": the default, never "None only".</summary>
[Fact]
public void Empty_json_array_is_treated_as_not_configured_and_yields_the_default()
{
var json = """{ "OpcUa": { "EnabledSecurityProfiles": [] } }""";
var configuration = new ConfigurationBuilder()
.AddJsonStream(new MemoryStream(Encoding.UTF8.GetBytes(json))).Build();
var options = Resolve(configuration, out var provider);
using var sp = provider;
options.Value.ResolveEnabledSecurityProfiles().ShouldBe(AllThree);
}
/// <summary>A list that is set but empty fails startup validation and cannot be resolved — it never means "None".</summary>
[Fact]
public void An_explicitly_empty_list_fails_validation_and_resolution()
{
var empty = new OpcUaApplicationHostOptions { EnabledSecurityProfiles = new List<OpcUaSecurityProfile>() };
new OpcUaApplicationHostOptionsValidator().Validate(null, empty).Failed.ShouldBeTrue();
Should.Throw<InvalidOperationException>(() => empty.ResolveEnabledSecurityProfiles());
}
/// <summary>An unparseable profile name fails startup rather than being ignored (which would otherwise leave the default).</summary>
[Fact]
public void An_unknown_profile_name_fails_options_resolution()
{
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["OpcUa:EnabledSecurityProfiles:0"] = "Basic256Sha256-SignAndEncrypt",
}).Build();
var options = Resolve(configuration, out var provider);
using var sp = provider;
Should.Throw<Exception>(() => _ = options.Value.ResolveEnabledSecurityProfiles());
}
}