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());
}
}