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; /// /// lmxopcua#879 — ConfigurationBinder appends configured collection items to a pre-filled list, so /// OpcUa:EnabledSecurityProfiles could never drop None. These tests pin "the configured list REPLACES /// the default" through the same AddValidatedOptions wiring Program.cs uses, for the indexed /// (env-var / in-memory) form and the JSON array form, plus the unchanged default and the empty-list rule. /// public sealed class OpcUaSecurityProfilesBindingTests { private static readonly OpcUaSecurityProfile[] AllThree = [ OpcUaSecurityProfile.None, OpcUaSecurityProfile.Basic256Sha256Sign, OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt, ]; private static IOptions Resolve(IConfiguration configuration, out ServiceProvider provider) { var services = new ServiceCollection(); services.AddSingleton(configuration); services.AddValidatedOptions(configuration, "OpcUa"); provider = services.BuildServiceProvider(); return provider.GetRequiredService>(); } /// The #879 repro: only the secure profiles configured gives exactly those, no None. [Fact] public void Configured_secure_profiles_replace_the_default_and_drop_None() { var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["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); } /// The JSON array form (appsettings.json, as docs/security.md shows) behaves the same as the indexed form. [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]); } /// The environment-variable form (OpcUa__EnabledSecurityProfiles__0) used by the Windows service gate. [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); } } /// Configuring None explicitly alongside the secure profiles still works (an operator may want it). [Fact] public void Explicit_None_is_honoured_when_listed() { var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["OpcUa:EnabledSecurityProfiles:0"] = "None", }).Build(); var options = Resolve(configuration, out var provider); using var sp = provider; options.Value.ResolveEnabledSecurityProfiles().ShouldBe([OpcUaSecurityProfile.None]); } /// Nothing configured: the three-profile default is applied, unchanged from before the fix. [Fact] public void Nothing_configured_keeps_the_three_profile_default() { var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["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); } /// An empty JSON array binds to nothing, i.e. "not configured": the default, never "None only". [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); } /// A list that is set but empty fails startup validation and cannot be resolved — it never means "None". [Fact] public void An_explicitly_empty_list_fails_validation_and_resolution() { var empty = new OpcUaApplicationHostOptions { EnabledSecurityProfiles = new List() }; new OpcUaApplicationHostOptionsValidator().Validate(null, empty).Failed.ShouldBeTrue(); Should.Throw(() => empty.ResolveEnabledSecurityProfiles()); } /// An unparseable profile name fails startup rather than being ignored (which would otherwise leave the default). [Fact] public void An_unknown_profile_name_fails_options_resolution() { var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["OpcUa:EnabledSecurityProfiles:0"] = "Basic256Sha256-SignAndEncrypt", }).Build(); var options = Resolve(configuration, out var provider); using var sp = provider; Should.Throw(() => _ = options.Value.ResolveEnabledSecurityProfiles()); } }