using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests; /// /// Gitea #532 — AkkaClusterOptions.Roles must actually apply the OTOPCUA_ROLES fallback /// its own XML doc has always described. /// /// /// /// These go through AddOtOpcUaCluster + a real service provider rather than calling /// RoleParser.ResolveEffectiveRoles directly, because the defect was never in the parsing — /// it was that nothing called it on the way to the bound options. A unit test of the /// helper alone would have passed against the broken tree. /// /// /// The consequence being pinned: BuildClusterOptions feeds Roles straight to Akka as /// the node's gossiped member roles. Empty there means a node that joins, comes Up, serves OPC UA /// — and is invisible to RoleLeader, to MembersWithRole("driver"), to every /// role-scoped singleton, and to ConfigPublishCoordinator's expected-ack set. The field /// symptom was "no driver-role nodes are Up" with nothing wrong in the node's own logs /// (docs/deployments/wonder-app-vd03-makino-z-34184.md). /// /// [Collection(RoleEnvironmentCollection.Name)] public class AkkaClusterOptionsRoleFallbackTests { private static AkkaClusterOptions Bind(params (string Key, string Value)[] settings) { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(settings.ToDictionary(s => s.Key, s => (string?)s.Value)) .Build(); var services = new ServiceCollection(); services.AddLogging(); services.AddOtOpcUaCluster(configuration); return services.BuildServiceProvider().GetRequiredService>().Value; } [Fact] public void Roles_fall_back_to_the_environment_when_Cluster_Roles_is_empty() { using (new EnvVarScope(RoleParser.RolesEnvVar, "admin,driver")) { Bind().Roles.ShouldBe(new[] { "admin", "driver" }); } } [Fact] public void Akka_member_roles_carry_the_fallback() { // The end that matters. This is the value handed to Akka.Cluster.Hosting, so an empty array here // is a role-less member — the exact production shape #532 describes. using (new EnvVarScope(RoleParser.RolesEnvVar, "driver")) { ServiceCollectionExtensions.BuildClusterOptions(Bind()).Roles.ShouldBe(new[] { "driver" }); } } [Fact] public void Configured_roles_win_over_the_environment() { // Negative control for the fallback: without this, a PostConfigure that unconditionally // overwrote Roles from the environment would pass the test above and silently ignore every // deployment's explicit Cluster:Roles. using (new EnvVarScope(RoleParser.RolesEnvVar, "admin,driver,cluster-SITE-B")) { Bind(("Cluster:Roles:0", "driver"), ("Cluster:Roles:1", "cluster-SITE-A")).Roles .ShouldBe(new[] { "driver", "cluster-SITE-A" }); } } [Fact] public void No_roles_from_either_source_stays_empty() { using (new EnvVarScope(RoleParser.RolesEnvVar, null)) { Bind().Roles.ShouldBeEmpty(); } } [Fact] public void The_cluster_role_from_the_environment_reaches_the_ClusterId_derivation() { // The over-grant fix in ConfigDbApiKeyStore reads its ClusterId off these options, and a null // ClusterId applies no cluster filter at all — so an env-only node failed OPEN before #532. using (new EnvVarScope(RoleParser.RolesEnvVar, "driver,cluster-SITE-A")) { RoleParser.ClusterIdFromRoles(Bind().Roles).ShouldBe("site-a"); } } /// /// R3-04 (structural identity, choice 3): AkkaClusterOptions.Roles is not a SECOND /// derivation of the role rule — it is the registered instance's /// list, verbatim. A mixed-case configured role proves /// both the normalization (plain lowered, cluster suffix preserved) and the identity: the two /// views can never disagree because there is only one derivation. /// [Fact] public void Bound_options_Roles_IS_the_registered_EffectiveRoles_Gossiped_list() { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Cluster:Roles:0"] = "Driver", ["Cluster:Roles:1"] = "Cluster-SITE-A", }) .Build(); var services = new ServiceCollection(); services.AddLogging(); services.AddOtOpcUaCluster(configuration); var provider = services.BuildServiceProvider(); var bound = provider.GetRequiredService>().Value; var effectiveRoles = provider.GetRequiredService(); bound.Roles.ShouldBe(new[] { "driver", "cluster-SITE-A" }); bound.Roles.ShouldBe(effectiveRoles.Gossiped); } }