a83bf1fa27
v2-ci / unit-tests (push) Has been cancelled
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
157 lines
7.0 KiB
C#
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());
|
|
}
|
|
}
|