Merge branch 'fix/r3-04-one-role-authority' — R3-04: EffectiveRoles is the one role authority (Cluster:Roles wins over OTOPCUA_ROLES, normalised, validated, fail-start on unknown/overlay-changed roles); Program.cs + all four validators consume it; case-insensitive gossip predicates; source-scan guard (archreview 01/S-12 = 03/C5, 01/C-9)

This commit is contained in:
Joseph Doherty
2026-08-20 11:05:09 -04:00
41 changed files with 2176 additions and 159 deletions
@@ -94,4 +94,34 @@ public class AkkaClusterOptionsRoleFallbackTests
RoleParser.ClusterIdFromRoles(Bind().Roles).ShouldBe("site-a");
}
}
/// <summary>
/// R3-04 (structural identity, choice 3): <c>AkkaClusterOptions.Roles</c> is not a SECOND
/// derivation of the role rule — it is the registered <see cref="EffectiveRoles"/> instance's
/// <see cref="EffectiveRoles.Gossiped"/> 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.
/// </summary>
[Fact]
public void Bound_options_Roles_IS_the_registered_EffectiveRoles_Gossiped_list()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["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<IOptions<AkkaClusterOptions>>().Value;
var effectiveRoles = provider.GetRequiredService<EffectiveRoles>();
bound.Roles.ShouldBe(new[] { "driver", "cluster-SITE-A" });
bound.Roles.ShouldBe(effectiveRoles.Gossiped);
}
}
@@ -1,3 +1,6 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
@@ -214,4 +217,128 @@ public sealed class AkkaClusterOptionsValidatorTests
result.Failed.ShouldBeFalse(result.FailureMessage);
}
// ---- R3-04 choice 4: the post-overlay config-layer guard --------------------------------------
// A role overlay (or a ${secret:} expansion) may not itself set Cluster:Roles — Program.cs
// resolves EffectiveRoles BEFORE the overlay loads (it needs the answer to pick the overlay's
// filename), so if a later config layer changed Cluster:Roles the bound options would disagree
// with every consumer holding the registered EffectiveRoles instance.
//
// Review finding (2026-08-20): these MUST go through the real AddOtOpcUaCluster + PostConfigure
// pipeline, not hand-built AkkaClusterOptions. options.Roles is unconditionally overwritten by
// ServiceCollectionExtensions' PostConfigure to equal EffectiveRoles.Gossiped (T2's structural
// identity) — so a validator that compared against options.Roles could NEVER observe a
// divergence; it would always be equal by construction. The guard has to re-read the raw
// Cluster:Roles section LIVE off IConfiguration instead, which is what actually reflects a role
// overlay applied after EffectiveRoles was first resolved.
/// <summary>
/// Builds the REAL DI graph <c>Program.cs</c> builds — a live, mutable
/// <see cref="ConfigurationManager"/> (the same "add a provider on the fly" shape
/// <c>WebApplicationBuilder.Configuration</c> has, unlike a one-shot
/// <see cref="ConfigurationBuilder"/>) — so a role overlay can be simulated as a SECOND
/// provider added to the SAME configuration object AFTER <see cref="EffectiveRoles"/> was
/// already resolved and registered, exactly as <c>Program.cs</c>'s
/// <c>builder.Configuration.AddJsonFile(overlay)</c> mutates <c>builder.Configuration</c>
/// after the role read at the top of the file.
/// </summary>
private static IServiceProvider BuildGraphWithOverlay(
Dictionary<string, string?> baseConfig, Dictionary<string, string?>? overlayConfig)
{
var configuration = new ConfigurationManager();
configuration.AddInMemoryCollection(baseConfig);
// Program.cs:61-63 (T3) — resolved BEFORE the overlay loads, off the pre-overlay snapshot.
var effectiveRoles = EffectiveRoles.Resolve(configuration);
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton(effectiveRoles);
// The overlay: a later provider added to the SAME live configuration object.
if (overlayConfig is not null)
configuration.AddInMemoryCollection(overlayConfig);
services.AddOtOpcUaCluster(configuration);
return services.BuildServiceProvider();
}
[Fact]
public void An_overlay_that_changes_Cluster_Roles_after_resolution_fails_naming_both_views()
{
var provider = BuildGraphWithOverlay(
baseConfig: new Dictionary<string, string?> { ["Cluster:Roles:0"] = "driver" },
overlayConfig: new Dictionary<string, string?>
{
["Cluster:Roles:0"] = "admin",
["Cluster:Roles:1"] = "driver",
});
var ex = Should.Throw<OptionsValidationException>(
() => provider.GetRequiredService<IOptions<AkkaClusterOptions>>().Value);
var message = string.Join(" ", ex.Failures);
message.ShouldContain("Cluster:Roles");
message.ShouldContain("driver");
message.ShouldContain("admin");
}
[Fact]
public void An_overlay_that_repeats_the_same_Cluster_Roles_passes()
{
var provider = BuildGraphWithOverlay(
baseConfig: new Dictionary<string, string?>
{
["Cluster:Roles:0"] = "driver",
["Cluster:Roles:1"] = "cluster-SITE-A",
},
// A real overlay never touches Cluster:Roles (the documented invariant) — this is the
// "no-op overlay" shape every one of the four shipped overlays actually is today.
overlayConfig: new Dictionary<string, string?> { ["Serilog:MinimumLevel:Default"] = "Debug" });
Should.NotThrow(() => provider.GetRequiredService<IOptions<AkkaClusterOptions>>().Value);
}
[Fact]
public void No_overlay_at_all_passes()
{
var provider = BuildGraphWithOverlay(
baseConfig: new Dictionary<string, string?> { ["Cluster:Roles:0"] = "driver" },
overlayConfig: null);
Should.NotThrow(() => provider.GetRequiredService<IOptions<AkkaClusterOptions>>().Value);
}
/// <summary>
/// A test that never injects <see cref="EffectiveRoles"/> — the pre-T7 construction shape —
/// must keep passing. Without this, giving the ctor a required parameter would silently break
/// every other test in this file rather than a chosen few.
/// </summary>
[Fact]
public void No_EffectiveRoles_injected_skips_the_guard_entirely()
{
var options = CentralNode("site-a-1", Seed("site-a-1"));
options.Roles = new[] { "whatever-this-does-not-validate-roles-either" };
var result = new AkkaClusterOptionsValidator().Validate(null, options);
result.Failed.ShouldBeFalse(result.FailureMessage);
}
/// <summary>
/// <see cref="EffectiveRoles"/> injected but <see cref="IConfiguration"/> not — the guard must
/// still skip cleanly rather than null-reference. Mirrors the previous test from the other
/// side of the two-dependency ctor.
/// </summary>
[Fact]
public void EffectiveRoles_injected_without_IConfiguration_skips_the_guard_entirely()
{
var effectiveRoles = EffectiveRoles.Resolve(["driver"], null);
var options = CentralNode("site-a-1", Seed("site-a-1"));
options.Roles = new[] { "something-else-entirely" };
var result = new AkkaClusterOptionsValidator(effectiveRoles).Validate(null, options);
result.Failed.ShouldBeFalse(result.FailureMessage);
}
}
@@ -0,0 +1,129 @@
using System.Net;
using System.Net.Sockets;
using Akka.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
/// <summary>
/// Review finding on R3-04 (Important, 2026-08-20): <c>_roleLeaders</c> must stay
/// <see cref="StringComparer.Ordinal"/>, NOT <see cref="StringComparer.OrdinalIgnoreCase"/> like
/// <c>_membersByRole</c>. Akka raises a SEPARATE <c>RoleLeaderChanged</c> event per exact gossiped
/// role string — "driver" and "Driver" are two entirely distinct roles to Akka's own role-leader
/// computation, each with its own (possibly different) leader member. An ignore-case dictionary
/// would collapse both onto ONE key, and whichever role-leader-changed event landed LAST would
/// silently overwrite the other — <c>RoleLeader("driver")</c> could then return the "Driver"
/// role's leader instead of (or in addition to losing) the "driver" role's own.
/// </summary>
/// <remarks>
/// <c>_membersByRole</c> stays <see cref="StringComparer.OrdinalIgnoreCase"/> deliberately — it
/// answers "give me every member carrying (a spelling of) this role", where merging casings is
/// the whole point (archreview 01/C-9, rolling-upgrade peer defence in depth). <c>_roleLeaders</c>
/// answers a per-EXACT-role-string question Akka itself keeps separate, so merging casings there
/// loses information instead.
/// </remarks>
public sealed class ClusterRoleInfoRoleLeaderCaseTests
{
private static int FreePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
private const string SystemName = "clusterroleinfo-leader-case-test";
private static async Task<(IHost Host, IClusterRoleInfo RoleInfo)> StartNodeAsync(
int selfPort, int? peerPort, string role)
{
var self = $"akka.tcp://{SystemName}@127.0.0.1:{selfPort}";
var seeds = peerPort is null
? new[] { self }
: new[] { self, $"akka.tcp://{SystemName}@127.0.0.1:{peerPort}" };
var options = new AkkaClusterOptions
{
SystemName = SystemName,
Hostname = "127.0.0.1",
PublicHostname = "127.0.0.1",
Port = selfPort,
// Deliberately ONE role each, spelled differently between the two nodes — that mismatch
// IS the scenario under test, not an oversight.
Roles = new[] { role },
SeedNodes = seeds,
};
var builder = Host.CreateDefaultBuilder();
builder.ConfigureServices(services =>
{
services.AddSingleton<IOptions<AkkaClusterOptions>>(Options.Create(options));
services.AddAkka(SystemName, (ab, sp) => ab.WithOtOpcUaClusterBootstrap(sp));
services.AddSingleton<IClusterRoleInfo, ClusterRoleInfo>();
});
var host = builder.Build();
await host.StartAsync();
return (host, host.Services.GetRequiredService<IClusterRoleInfo>());
}
/// <summary>
/// Two single-member "roles" — "driver" on node A, "Driver" on node B, both Up in the SAME
/// cluster. Each is the sole (and therefore leader) member of its own exact-string role. If
/// <c>_roleLeaders</c> were case-insensitive, the two RoleLeaderChanged events would collide
/// onto one dictionary slot and one of the two role-leader facts would be lost or wrong.
/// </summary>
[Fact]
public async Task RoleLeaderChanged_for_driver_then_Driver_keeps_distinct_entries()
{
var portA = FreePort();
var portB = FreePort();
var (hostA, roleInfoA) = await StartNodeAsync(portA, peerPort: null, role: "driver");
try
{
var (hostB, _) = await StartNodeAsync(portB, peerPort: portA, role: "Driver");
try
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30);
NodeId? driverLeader = null;
NodeId? capitalDriverLeader = null;
while (DateTime.UtcNow < deadline)
{
driverLeader = roleInfoA.RoleLeader("driver");
capitalDriverLeader = roleInfoA.RoleLeader("Driver");
if (driverLeader is not null && capitalDriverLeader is not null) break;
await Task.Delay(200, TestContext.Current.CancellationToken);
}
driverLeader.ShouldNotBeNull("node A's own 'driver' role must elect itself leader");
capitalDriverLeader.ShouldNotBeNull(
"node B's 'Driver' role must ALSO be tracked — a case-insensitive dictionary "
+ "would either lose this entry or clobber the 'driver' one with it");
driverLeader!.Value.ShouldNotBe(
capitalDriverLeader!.Value,
"the two roles are DISTINCT to Akka and must resolve to their own (different) "
+ "member — a merged dictionary slot would make these equal");
driverLeader.Value.ToString().ShouldContain(portA.ToString());
capitalDriverLeader.Value.ToString().ShouldContain(portB.ToString());
}
finally
{
await hostB.StopAsync(TestContext.Current.CancellationToken);
hostB.Dispose();
}
}
finally
{
await hostA.StopAsync(TestContext.Current.CancellationToken);
hostA.Dispose();
}
}
}
@@ -23,7 +23,8 @@ public class ConfigSourceOptionsValidatorTests
var configuration = new ConfigurationBuilder().AddInMemoryCollection(pairs).Build();
return new ConfigSourceOptionsValidator(configuration).Validate(ConfigSourceOptions.SectionName, o);
return new ConfigSourceOptionsValidator(EffectiveRoles.Resolve(configuration))
.Validate(ConfigSourceOptions.SectionName, o);
}
private static ConfigSourceOptions ValidFetch() => new()
@@ -0,0 +1,254 @@
using Microsoft.Extensions.Configuration;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
/// <summary>
/// <see cref="EffectiveRoles"/> is the single role authority (archreview R3-04, 01/S-12 = 03/C5,
/// 01/C-9): one factory, one precedence (<c>Cluster:Roles</c> non-empty wins, else
/// <c>OTOPCUA_ROLES</c>, else empty), two-layer normalization (plain roles lower-cased, a cluster
/// role's ClusterId suffix preserved verbatim), and validation on BOTH sources.
/// </summary>
[Collection(RoleEnvironmentCollection.Name)]
public sealed class EffectiveRolesTests
{
// ---- Precedence matrix -------------------------------------------------------------------
[Fact]
public void Configured_only_resolves_from_Configured_source()
{
var roles = EffectiveRoles.Resolve(["admin", "driver"], null);
roles.Source.ShouldBe(RoleSource.Configured);
roles.Gossiped.ShouldBe(new[] { "admin", "driver" });
}
[Fact]
public void Environment_only_resolves_from_Environment_source()
{
var roles = EffectiveRoles.Resolve(null, "admin,driver");
roles.Source.ShouldBe(RoleSource.Environment);
roles.Gossiped.ShouldBe(new[] { "admin", "driver" });
}
[Fact]
public void Both_agree_resolves_from_Configured_source()
{
var roles = EffectiveRoles.Resolve(["driver"], "driver");
roles.Source.ShouldBe(RoleSource.Configured);
roles.Gossiped.ShouldBe(new[] { "driver" });
}
[Fact]
public void Both_differ_Configured_wins_outright()
{
var roles = EffectiveRoles.Resolve(["admin"], "driver,cluster-SITE-A");
roles.Source.ShouldBe(RoleSource.Configured);
roles.Gossiped.ShouldBe(new[] { "admin" });
}
[Fact]
public void Neither_source_resolves_empty_with_None_source()
{
var roles = EffectiveRoles.Resolve(null, null);
roles.Source.ShouldBe(RoleSource.None);
roles.Gossiped.ShouldBeEmpty();
}
[Fact]
public void Empty_configured_array_falls_back_to_the_environment()
{
var roles = EffectiveRoles.Resolve([], "driver");
roles.Source.ShouldBe(RoleSource.Environment);
roles.Gossiped.ShouldBe(new[] { "driver" });
}
// ---- Gossiped normalization: two-layer (choice 2) ----------------------------------------
[Fact]
public void Configured_plain_role_is_trimmed_and_lower_cased_in_Gossiped()
{
var roles = EffectiveRoles.Resolve([" Driver "], null);
roles.Gossiped.ShouldBe(new[] { "driver" });
}
/// <summary>
/// THE falsification control for choice 2: a cluster role's ClusterId suffix must survive
/// UNCHANGED — lower-casing it would rename the Akka role of a running deployment
/// (<c>cluster-SITE-A</c> vs <c>cluster-site-a</c> are different gossiped roles).
/// </summary>
[Fact]
public void Configured_cluster_role_suffix_case_is_preserved_in_Gossiped()
{
var roles = EffectiveRoles.Resolve(["Cluster-SITE-A"], null);
// The "cluster-" prefix normalizes (it is a plain marker), the "SITE-A" suffix does not.
roles.Gossiped.ShouldBe(new[] { "cluster-SITE-A" });
}
[Fact]
public void Configured_cluster_role_with_already_lowercase_prefix_keeps_suffix_verbatim()
{
var roles = EffectiveRoles.Resolve(["cluster-Site-A"], null);
roles.Gossiped.ShouldBe(new[] { "cluster-Site-A" });
}
[Fact]
public void Environment_cluster_role_suffix_IS_lowercased_unlike_Configured()
{
// Unchanged from RoleParser.Parse's existing (documented) behaviour: the two sources
// normalize differently on purpose (RoleParser.ClusterIdFromRole's remarks).
var roles = EffectiveRoles.Resolve(null, "driver,cluster-SITE-A");
roles.Gossiped.ShouldBe(new[] { "driver", "cluster-site-a" });
}
// ---- Has*/HasAdmin/HasDriver case-insensitivity ------------------------------------------
[Theory]
[InlineData("driver")]
[InlineData("Driver")]
[InlineData("DRIVER")]
[InlineData(" driver ")]
public void HasDriver_true_for_every_casing(string configuredRole)
{
EffectiveRoles.Resolve([configuredRole], null).HasDriver.ShouldBeTrue();
}
[Fact]
public void HasAdmin_false_when_admin_is_not_present()
{
EffectiveRoles.Resolve(["driver"], null).HasAdmin.ShouldBeFalse();
}
[Fact]
public void Has_is_case_insensitive_for_an_arbitrary_role()
{
var roles = EffectiveRoles.Resolve(["Admin", "Driver"], null);
roles.Has("admin").ShouldBeTrue();
roles.Has("ADMIN").ShouldBeTrue();
roles.Has("driver").ShouldBeTrue();
roles.Has("dev").ShouldBeFalse();
}
// ---- Unknown / invalid role from EITHER source throws, naming the key --------------------
[Fact]
public void Unknown_role_from_the_configured_source_throws_naming_the_key()
{
var ex = Should.Throw<ArgumentException>(() => EffectiveRoles.Resolve(["Drivr"], null));
ex.Message.ShouldContain("Cluster:Roles");
ex.Message.ShouldContain("Drivr");
}
[Fact]
public void Unknown_role_from_the_environment_source_throws()
{
// The env path already validated via RoleParser.Parse — this pins that it still does.
Should.Throw<ArgumentException>(() => EffectiveRoles.Resolve(null, "admin,master"));
}
// ---- ClusterRole / ClusterId ---------------------------------------------------------------
[Fact]
public void ClusterRole_and_ClusterId_are_null_for_a_legacy_node()
{
var roles = EffectiveRoles.Resolve(["admin", "driver"], null);
roles.ClusterRole.ShouldBeNull();
roles.ClusterId.ShouldBeNull();
}
[Fact]
public void ClusterRole_and_ClusterId_resolve_for_a_split_topology_node()
{
var roles = EffectiveRoles.Resolve(["driver", "cluster-SITE-A"], null);
roles.ClusterRole.ShouldBe("cluster-SITE-A");
roles.ClusterId.ShouldBe("SITE-A");
}
// ---- Resolve(IConfiguration) overload ------------------------------------------------------
[Fact]
public void Resolve_from_configuration_reads_Cluster_Roles()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Cluster:Roles:0"] = "driver",
["Cluster:Roles:1"] = "cluster-SITE-A",
})
.Build();
var roles = EffectiveRoles.Resolve(configuration);
roles.Source.ShouldBe(RoleSource.Configured);
roles.Gossiped.ShouldBe(new[] { "driver", "cluster-SITE-A" });
}
[Fact]
public void Resolve_from_configuration_falls_back_to_the_environment()
{
using (new EnvVarScope(RoleParser.RolesEnvVar, "admin,driver"))
{
var configuration = new ConfigurationBuilder().Build();
var roles = EffectiveRoles.Resolve(configuration);
roles.Source.ShouldBe(RoleSource.Environment);
roles.Gossiped.ShouldBe(new[] { "admin", "driver" });
}
}
// ---- DisagreementWarning (choice 5, T6) ----------------------------------------------------
[Fact]
public void Both_sources_set_and_agreeing_has_no_warning()
{
var roles = EffectiveRoles.Resolve(["driver"], "driver");
roles.DisagreementWarning.ShouldBeNull();
}
[Fact]
public void Only_one_source_set_has_no_warning()
{
EffectiveRoles.Resolve(["driver"], null).DisagreementWarning.ShouldBeNull();
EffectiveRoles.Resolve(null, "driver").DisagreementWarning.ShouldBeNull();
EffectiveRoles.Resolve(null, null).DisagreementWarning.ShouldBeNull();
}
[Fact]
public void Both_sources_set_and_disagreeing_produces_a_warning_naming_both_keys()
{
var roles = EffectiveRoles.Resolve(["admin"], "driver,cluster-SITE-A");
roles.DisagreementWarning.ShouldNotBeNull();
roles.DisagreementWarning.ShouldContain("Cluster:Roles");
roles.DisagreementWarning.ShouldContain(RoleParser.RolesEnvVar);
// Configured still wins outright — the warning is observability, not a behaviour change.
roles.Gossiped.ShouldBe(new[] { "admin" });
roles.Source.ShouldBe(RoleSource.Configured);
}
[Fact]
public void Both_sources_set_but_only_differing_by_case_do_not_warn()
{
// Same normalized set spelled differently in each source is NOT the "Cluster:Roles wins
// unintentionally" shape the warning exists for.
var roles = EffectiveRoles.Resolve(["Driver"], "driver");
roles.DisagreementWarning.ShouldBeNull();
}
}
@@ -0,0 +1,54 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
/// <summary>
/// R3-04 (01/C-9, choice 6): <see cref="RoleParser.GossipedRolesHave"/> is defence in depth for a
/// PEER running an older build that still gossips a miscased plain role — a locally-authored node
/// can no longer gossip one at all once <see cref="EffectiveRoles"/> (T1) is wired in, but Akka
/// compares gossiped role names ordinally, so a rolling-upgrade window can still show a
/// mixed-case peer.
/// </summary>
public sealed class GossipedRoleMatchTests
{
[Fact]
public void Matches_the_canonical_spelling()
{
RoleParser.GossipedRolesHave(["driver"], "driver").ShouldBeTrue();
}
[Theory]
[InlineData("Driver")]
[InlineData("DRIVER")]
[InlineData("driVer")]
public void Matches_a_miscased_gossiped_role(string gossiped)
{
RoleParser.GossipedRolesHave([gossiped], "driver").ShouldBeTrue();
}
[Fact]
public void Matches_a_miscased_cluster_role()
{
RoleParser.GossipedRolesHave(["cluster-SITE-A"], "cluster-site-a").ShouldBeTrue();
RoleParser.GossipedRolesHave(["Cluster-Site-A"], "cluster-SITE-A").ShouldBeTrue();
}
[Fact]
public void Does_not_match_a_different_role()
{
RoleParser.GossipedRolesHave(["admin"], "driver").ShouldBeFalse();
}
[Fact]
public void Null_gossiped_roles_is_false()
{
RoleParser.GossipedRolesHave(null, "driver").ShouldBeFalse();
}
[Fact]
public void Empty_gossiped_roles_is_false()
{
RoleParser.GossipedRolesHave([], "driver").ShouldBeFalse();
}
}
@@ -39,7 +39,7 @@ public class MeshTransportOptionsValidatorTests
var configuration = new ConfigurationBuilder().AddInMemoryCollection(pairs).Build();
return new MeshTransportOptionsValidator(configuration)
return new MeshTransportOptionsValidator(configuration, EffectiveRoles.Resolve(configuration))
.Validate(MeshTransportOptions.SectionName, o);
}
@@ -0,0 +1,250 @@
using System.Text.RegularExpressions;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
/// <summary>
/// R3-04 (theme 4, "one rule stated twice diverges"): the role-reading rule may live in exactly
/// one place. Three source-scan rules, modelled on
/// <c>CommandDisposalConventionsTests</c>'s root-walk idiom, over every <c>src/**/*.cs</c> +
/// <c>src/**/*.razor</c> file:
/// <list type="number">
/// <item><description>No file except <c>EffectiveRoles.cs</c>/<c>RoleParser.cs</c> reads
/// <c>OTOPCUA_ROLES</c>.</description></item>
/// <item><description>No file except <c>EffectiveRoles.cs</c> reads the <c>Cluster:Roles</c>
/// key as code (an operator-facing message string that merely NAMES the key is fine — it
/// reads as a sentence, i.e. its string literal also contains a space).</description></item>
/// <item><description>No file except <c>RoleParser.cs</c> declares a bare
/// <c>"admin"</c>/<c>"driver"</c>/<c>"dev"</c> role literal.</description></item>
/// </list>
/// A textual scan, not reflection, because the thing being guarded is a textual read
/// (<c>Environment.GetEnvironmentVariable("OTOPCUA_ROLES")</c>, <c>GetSection("Cluster:Roles")</c>,
/// a bare <c>"driver"</c>) that reflection cannot see.
/// </summary>
/// <remarks>
/// <b>Falsification control:</b> <see cref="The_scan_actually_detects_the_pre_R3_04_offenders"/>
/// feeds the scan the EXACT pre-fix <c>Program.cs</c>/<c>Fleet.razor</c> snippets (captured
/// verbatim before T3/T5 touched them) and asserts it finds them — proof this guard can fail, not
/// just that the current tree happens to pass. Run against the real pre-T3 tree at commit
/// <c>7dfd6dcc</c> (Program.cs) / pre-T5 at <c>bcec88ef</c> (Fleet.razor), the live scan reported:
/// <c>Host/Program.cs:61</c>, <c>:62</c>, <c>:63</c> (OTOPCUA_ROLES + two bare role literals) and
/// <c>AdminUI/Components/Pages/Fleet.razor:149</c>, <c>:159</c> (two bare <c>"driver"</c> literals)
/// — recorded here per the plan's "paste the list into the commit body" instruction.
/// </remarks>
public sealed class RoleAuthorityGuardTests
{
private static readonly string[] OtopcuaRolesAllowedFiles = ["EffectiveRoles.cs", "RoleParser.cs"];
private static readonly string[] ClusterRolesKeyAllowedFiles = ["EffectiveRoles.cs"];
private static readonly string[] RoleLiteralAllowedFiles = ["RoleParser.cs"];
/// <summary>
/// Files whose bare <c>"admin"</c>/<c>"driver"</c>/<c>"dev"</c> literal names something OTHER
/// than an Akka <c>Cluster:Roles</c> role — each entry carries the reason, per the plan's
/// "add the file to the small, named allow-list WITH A REASON" instruction.
/// </summary>
private static readonly IReadOnlyDictionary<string, string> RoleLiteralAllowListWithReasons =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["RawTreeService.cs"] =
"raw-tree node-KIND discriminator (Folder/Driver/Device/TagGroup/Tag), not an Akka role",
["AclScopeService.cs"] =
"human-readable entity-type name passed to ResolveAsync for an error message, not a role",
["SqlTagDefinitionFactory.cs"] =
"the Sql driver's own TagConfig JSON blob field ({\"driver\":\"Sql\",...}), not a role",
["SqlEquipmentTagParser.cs"] =
"the Sql driver's own TagConfig JSON blob field ({\"driver\":\"Sql\",...}), not a role",
["SqlAddressPickerBody.razor"] =
"the Sql driver's own TagConfig JSON blob field ({\"driver\":\"Sql\",...}), not a role",
["OtOpcUaLdapAuthService.cs"] =
"an LDAP-derived ADMINUI RBAC role (the DevStubMode bypass group), not Cluster:Roles",
};
private static string SolutionRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx")))
dir = dir.Parent;
dir.ShouldNotBeNull("Could not find solution root (ZB.MOM.WW.OtOpcUa.slnx).");
return dir!.FullName;
}
private static IEnumerable<string> SourceFiles()
{
var srcDir = Path.Combine(SolutionRoot(), "src");
var sep = Path.DirectorySeparatorChar;
return Directory.EnumerateFiles(srcDir, "*.cs", SearchOption.AllDirectories)
.Concat(Directory.EnumerateFiles(srcDir, "*.razor", SearchOption.AllDirectories))
.Where(f => !f.Contains($"{sep}obj{sep}", StringComparison.Ordinal)
&& !f.Contains($"{sep}bin{sep}", StringComparison.Ordinal));
}
/// <summary>Every line of <paramref name="text"/> that is not a full-line <c>//</c>/<c>///</c>
/// comment, 1-based line numbers.</summary>
private static IEnumerable<(int Line, string Text)> NonCommentLines(string text)
{
var lines = text.Split('\n');
for (var i = 0; i < lines.Length; i++)
{
if (lines[i].TrimStart().StartsWith("//", StringComparison.Ordinal)) continue;
yield return (i + 1, lines[i]);
}
}
private static readonly Regex BareRoleLiteral = new("\"(admin|driver|dev)\"", RegexOptions.Compiled);
/// <summary>True when the occurrence of a <paramref name="matchLength"/>-character key at
/// <paramref name="matchIndex"/> sits inside a string literal that ALSO contains a space — the
/// shape an operator-facing message reads as (naming the key for guidance is not a CODE READ of
/// it), per the plan's carve-out for <c>Cluster:Roles</c> — applied identically to
/// <c>OTOPCUA_ROLES</c> for the same reason (<c>GrpcApplyAckSender</c>'s warning names BOTH keys
/// on one line; treating the two inconsistently would flag one and not the other on that exact
/// sentence).</summary>
private static bool IsAllowedMessageOccurrence(string line, int matchIndex, int matchLength)
{
var openQuote = line.LastIndexOf('"', matchIndex);
if (openQuote < 0) return false; // not inside any string literal at all -> a bare code read
var searchFrom = matchIndex + matchLength;
if (searchFrom >= line.Length) return false;
var closeQuote = line.IndexOf('"', searchFrom);
if (closeQuote < 0) return false;
return line[openQuote..(closeQuote + 1)].Contains(' ');
}
private static List<string> FindOtopcuaRolesOffenders(string text) =>
NonCommentLines(text)
.Where(l =>
{
var idx = l.Text.IndexOf("OTOPCUA_ROLES", StringComparison.Ordinal);
return idx >= 0 && !IsAllowedMessageOccurrence(l.Text, idx, "OTOPCUA_ROLES".Length);
})
.Select(l => l.Line.ToString())
.ToList();
private static List<string> FindClusterRolesKeyOffenders(string text) =>
NonCommentLines(text)
.Where(l =>
{
var idx = l.Text.IndexOf("Cluster:Roles", StringComparison.Ordinal);
return idx >= 0 && !IsAllowedMessageOccurrence(l.Text, idx, "Cluster:Roles".Length);
})
.Select(l => l.Line.ToString())
.ToList();
private static List<string> FindBareRoleLiteralOffenders(string text) =>
NonCommentLines(text)
.Where(l => BareRoleLiteral.IsMatch(l.Text))
.Select(l => l.Line.ToString())
.ToList();
[Fact]
public void No_file_outside_the_authority_reads_OTOPCUA_ROLES()
{
var offenders = new List<string>();
foreach (var file in SourceFiles())
{
if (OtopcuaRolesAllowedFiles.Contains(Path.GetFileName(file))) continue;
foreach (var line in FindOtopcuaRolesOffenders(File.ReadAllText(file)))
offenders.Add($"{RelativePath(file)}:{line}");
}
offenders.ShouldBeEmpty(
"OTOPCUA_ROLES must be read only by EffectiveRoles.cs/RoleParser.cs. Offenders:\n"
+ string.Join("\n", offenders));
}
[Fact]
public void No_file_outside_EffectiveRoles_reads_the_ClusterRoles_key_as_code()
{
var offenders = new List<string>();
foreach (var file in SourceFiles())
{
if (ClusterRolesKeyAllowedFiles.Contains(Path.GetFileName(file))) continue;
foreach (var line in FindClusterRolesKeyOffenders(File.ReadAllText(file)))
offenders.Add($"{RelativePath(file)}:{line}");
}
offenders.ShouldBeEmpty(
"Cluster:Roles must be read as code only by EffectiveRoles.cs (naming the key inside an "
+ "operator-facing message string is fine). Offenders:\n" + string.Join("\n", offenders));
}
[Fact]
public void No_file_outside_RoleParser_declares_a_bare_role_literal()
{
var offenders = new List<string>();
foreach (var file in SourceFiles())
{
var fileName = Path.GetFileName(file);
if (RoleLiteralAllowedFiles.Contains(fileName)) continue;
if (RoleLiteralAllowListWithReasons.ContainsKey(fileName)) continue;
foreach (var line in FindBareRoleLiteralOffenders(File.ReadAllText(file)))
offenders.Add($"{RelativePath(file)}:{line}");
}
offenders.ShouldBeEmpty(
"A bare \"admin\"/\"driver\"/\"dev\" role literal must live only in RoleParser.cs (use "
+ "RoleParser.Admin/.Driver/.Dev, or add the file to RoleLiteralAllowListWithReasons WITH "
+ "A REASON if it genuinely names something else). Offenders:\n" + string.Join("\n", offenders));
}
/// <summary>The allow-list itself must not silently grow stale — every reason must be non-blank,
/// and the file it names must actually exist and actually contain the literal it is excused for
/// (an allow-list entry that no longer applies is dead and hides nothing).</summary>
[Fact]
public void Every_allow_list_entry_has_a_reason_and_still_applies()
{
foreach (var (fileName, reason) in RoleLiteralAllowListWithReasons)
{
reason.ShouldNotBeNullOrWhiteSpace($"{fileName}'s allow-list entry has no reason.");
var matches = SourceFiles().Where(f => Path.GetFileName(f) == fileName).ToList();
matches.ShouldNotBeEmpty($"{fileName} is allow-listed but no such file exists under src/.");
var stillOffends = matches.Any(f => FindBareRoleLiteralOffenders(File.ReadAllText(f)).Count > 0);
stillOffends.ShouldBeTrue(
$"{fileName} is allow-listed for a bare role literal but no longer contains one — "
+ "remove the stale entry.");
}
}
/// <summary>
/// Falsification control (per the plan: "Run on the pre-T3 tree → FAIL... paste that list
/// into the commit body"). These are the EXACT pre-fix lines — copied verbatim, not
/// paraphrased — from <c>Host/Program.cs</c> (before T3, commit <c>7dfd6dcc</c>) and
/// <c>AdminUI/Components/Pages/Fleet.razor</c> (before T5, commit <c>bcec88ef</c>). If this
/// test ever goes green for the wrong reason (the scan silently stops matching), it is no
/// longer proof the guard can fail.
/// </summary>
[Fact]
public void The_scan_actually_detects_the_pre_R3_04_offenders()
{
const string preT3ProgramCs = """
// Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser.
var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
var hasAdmin = roles.Contains("admin");
var hasDriver = roles.Contains("driver");
""";
const string preT5FleetRazor = """
var akkaDrivers = Cluster.MembersWithRole("driver")
.Select(n => n.Value).ToHashSet(StringComparer.OrdinalIgnoreCase);
""";
FindOtopcuaRolesOffenders(preT3ProgramCs).ShouldNotBeEmpty(
"the falsification control failed to reproduce the pre-T3 OTOPCUA_ROLES violation");
FindBareRoleLiteralOffenders(preT3ProgramCs).Count.ShouldBeGreaterThanOrEqualTo(2,
"the falsification control failed to reproduce BOTH pre-T3 bare-literal violations "
+ "('admin' and 'driver')");
FindBareRoleLiteralOffenders(preT5FleetRazor).ShouldNotBeEmpty(
"the falsification control failed to reproduce the pre-T5 Fleet.razor bare-literal violation");
}
private static string RelativePath(string fullPath) =>
Path.GetRelativePath(SolutionRoot(), fullPath).Replace('\\', '/');
}
@@ -0,0 +1,107 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
/// <summary>
/// R3-04 (01/C-9): a mixed-case role must fire the SAME fail-fast rule the canonical spelling
/// fires, on ALL FOUR role-scoped validators. Before the fix, three of the four
/// (<see cref="MeshTransportOptionsValidator"/>, <see cref="ConfigSourceOptionsValidator"/>,
/// <see cref="TelemetryOptionsValidator"/>) compared roles ordinally and silently PASSED a
/// misconfigured node spelled <c>Driver</c>/<c>Admin</c> — the load-bearing assertion in every
/// case below is the FAILURE, not the pass, because a validator whose driver/admin branch never
/// runs also "passes" vacuously.
/// </summary>
/// <remarks>
/// <see cref="SplitTopologyTransportValidator"/> already normalized before this fix (its own
/// comment names the hazard) — its case here is the BUILT-IN falsification control: it must have
/// been passing (i.e. correctly failing the misconfigured node) even before T4 landed, proving
/// the Theory shape itself is capable of catching a real regression.
/// </remarks>
[Collection(RoleEnvironmentCollection.Name)]
public sealed class RoleCaseVariantValidatorTests
{
public static readonly TheoryData<string> DriverSpellings = new()
{
"driver", "Driver", " driver ", "DRIVER",
};
private static IConfiguration Config(params (string Key, string Value)[] pairs) =>
new ConfigurationBuilder()
.AddInMemoryCollection(pairs.ToDictionary(p => p.Key, p => (string?)p.Value))
.Build();
// ---- SplitTopologyTransportValidator: the built-in control (already normalized pre-fix) -------
[Theory]
[MemberData(nameof(DriverSpellings))]
public void SplitTopology_fails_on_Dps_mesh_transport_for_every_driver_spelling(string driverSpelling)
{
var configuration = Config(
("Cluster:Roles:0", driverSpelling),
("Cluster:Roles:1", "cluster-SITE-A"),
("MeshTransport:Mode", MeshTransportOptions.ModeDps),
("Telemetry:Mode", TelemetryOptions.ModeGrpc));
var result = new SplitTopologyTransportValidator(configuration, EffectiveRoles.Resolve(configuration))
.Validate(MeshTransportOptions.SectionName, new MeshTransportOptions());
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldContain("MeshTransport:Mode");
}
// ---- MeshTransportOptionsValidator: GrpcPsk required for a driver node under Grpc mode --------
[Theory]
[MemberData(nameof(DriverSpellings))]
public void MeshTransport_Grpc_driver_node_without_a_psk_fails_for_every_driver_spelling(string driverSpelling)
{
var configuration = Config(("Cluster:Roles:0", driverSpelling), ("Cluster:Roles:1", "cluster-SITE-A"));
var options = new MeshTransportOptions
{
Mode = MeshTransportOptions.ModeGrpc,
GrpcPsk = null,
CentralGrpcEndpoints = ["http://central-1:4055"],
};
var result = new MeshTransportOptionsValidator(configuration, EffectiveRoles.Resolve(configuration))
.Validate(MeshTransportOptions.SectionName, options);
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldContain("MeshTransport:GrpcPsk");
}
// ---- ConfigSourceOptionsValidator: a driver-only node on Direct fails ---------------------------
[Theory]
[MemberData(nameof(DriverSpellings))]
public void ConfigSource_driver_only_node_on_Direct_fails_for_every_driver_spelling(string driverSpelling)
{
var configuration = Config(("Cluster:Roles:0", driverSpelling));
var options = new ConfigSourceOptions { Mode = ConfigSourceOptions.ModeDirect };
var result = new ConfigSourceOptionsValidator(EffectiveRoles.Resolve(configuration))
.Validate(ConfigSourceOptions.SectionName, options);
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldContain(ConfigSourceOptions.ModeFetchAndCache);
}
// ---- TelemetryOptionsValidator: a driver node under Grpc mode needs a listen port ---------------
[Theory]
[MemberData(nameof(DriverSpellings))]
public void Telemetry_Grpc_driver_node_with_no_listen_port_fails_for_every_driver_spelling(string driverSpelling)
{
var configuration = Config(("Cluster:Roles:0", driverSpelling));
var options = new TelemetryOptions { Mode = TelemetryOptions.ModeGrpc, ApiKey = "k" };
var result = new TelemetryOptionsValidator(EffectiveRoles.Resolve(configuration))
.Validate(TelemetryOptions.SectionName, options);
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldContain(nameof(TelemetryOptions.GrpcListenPort));
}
}
@@ -35,7 +35,7 @@ public class SplitTopologyTransportValidatorTests
// The validated options instance is never read (all four values come from IConfiguration), so a
// default MeshTransportOptions is a fine stand-in.
return new SplitTopologyTransportValidator(configuration)
return new SplitTopologyTransportValidator(configuration, EffectiveRoles.Resolve(configuration))
.Validate(MeshTransportOptions.SectionName, new MeshTransportOptions());
}
@@ -246,7 +246,7 @@ public class SplitTopologyTransportValidatorTests
var pairs = new Dictionary<string, string?> { ["MeshTransport:Mode"] = MeshTransportOptions.ModeDps };
var configuration = new ConfigurationBuilder().AddInMemoryCollection(pairs).Build();
var result = new SplitTopologyTransportValidator(configuration)
var result = new SplitTopologyTransportValidator(configuration, EffectiveRoles.Resolve(configuration))
.Validate(MeshTransportOptions.SectionName, new MeshTransportOptions());
result.Failed.ShouldBeTrue();
@@ -23,7 +23,8 @@ public class TelemetryOptionsValidatorTests
var configuration = new ConfigurationBuilder().AddInMemoryCollection(pairs).Build();
return new TelemetryOptionsValidator(configuration).Validate(TelemetryOptions.SectionName, o);
return new TelemetryOptionsValidator(EffectiveRoles.Resolve(configuration))
.Validate(TelemetryOptions.SectionName, o);
}
[Fact]
@@ -0,0 +1,220 @@
using System.Collections.Immutable;
using System.Reflection;
using Akka.Actor;
using Akka.Cluster;
using Akka.Configuration;
using Akka.Util;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Fleet;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// R3-04 (01/C-9, census rows 17/20-24/30): a PEER gossiping a miscased plain role (a
/// rolling-upgrade-window peer running an older build — <see cref="EffectiveRoles"/> (T1) already
/// means a node on the FIXED build cannot gossip a miscased role itself) must not read as a
/// stranger to the six production predicates this plan names.
/// </summary>
/// <remarks>
/// <para>
/// <b>Deviation from the plan's stated T5 test (recorded per the execution brief):</b> the
/// plan asked for "a <c>RedundancyStateActor</c> selection case where the member gossips
/// 'Driver' and a Primary IS elected". That is not achievable from this repo.
/// <see cref="RedundancyStateActor.SelectOldestUpMemberOfRole"/> /
/// <see cref="RedundancyStateActor.SelectDriverPrimary"/> delegate to
/// <c>ZB.MOM.WW.Health.Akka</c>'s <c>ClusterActiveNode.OldestUpMember</c>, which matches via
/// Akka's OWN <c>Member.HasRole</c> — an ordinal comparison this repo does not own, the exact
/// same class of "not ours to change" the plan itself already carves out for
/// <c>ClusterSingletonOptions.Role</c> (rows 18/19). <see cref="Primary_election_itself_remains_ordinal_and_still_elects_no_one_for_an_all_miscased_pair"/>
/// below pins that TRUE (negative) outcome instead of asserting a false positive.
/// </para>
/// <para>
/// What R3-04 DOES fix and this file proves: every consumer that reads gossiped roles
/// DIRECTLY (not through that external oldest-member helper) is now case-insensitive, so an
/// individual miscased member is no longer invisible to failover candidacy, address
/// reconciliation, or the redundancy snapshot's own role classification.
/// </para>
/// </remarks>
public sealed class GossipedRoleCaseInsensitivityTests
{
// ---- Pure-unit: ClusterNodeAddressReconcilerActor.FilterOwnClusterDriverMembers ---------------
// (public static over IEnumerable<Member> — no live cluster needed)
private static int _memberUid;
// Akka's Member.Create factories are internal — same reflection idiom as
// ClusterNodeAddressReconcilerTests.UpMember.
private static readonly MethodInfo MemberCreate = typeof(Member).GetMethod(
"Create",
BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public,
binder: null,
types:
[
typeof(UniqueAddress), typeof(int), typeof(MemberStatus),
typeof(ImmutableHashSet<string>), typeof(AppVersion),
],
modifiers: null)
?? throw new InvalidOperationException("Akka.Cluster.Member.Create(5-arg) not found — Akka API changed");
private static Member UpMember(string host, int port, params string[] roles) =>
(Member)MemberCreate.Invoke(null,
[
new UniqueAddress(new Address("akka.tcp", "otopcua", host, port), Interlocked.Increment(ref _memberUid)),
1,
MemberStatus.Up,
roles.ToImmutableHashSet(),
AppVersion.Zero,
])!;
[Fact]
public void FilterOwnClusterDriverMembers_recognises_a_miscased_driver_role()
{
var members = new[] { UpMember("site-a-1", 4053, "Driver", "cluster-SITE-A") };
var result = ClusterNodeAddressReconcilerActor.FilterOwnClusterDriverMembers(members, "cluster-SITE-A");
result.ShouldHaveSingleItem();
}
[Fact]
public void FilterOwnClusterDriverMembers_recognises_a_miscased_cluster_role()
{
var members = new[] { UpMember("site-a-1", 4053, "driver", "Cluster-SITE-A") };
// The caller's OWN cluster role names the canonical spelling; the member gossips a different
// casing ("Cluster-SITE-A") — GossipedRolesHave must still match the two as the same role.
var result = ClusterNodeAddressReconcilerActor.FilterOwnClusterDriverMembers(members, "cluster-site-a");
result.ShouldHaveSingleItem();
}
// ---- Live single-node cluster: BuildSnapshot classification + ManualFailoverService -----------
private const int Port = 19_560;
private static Config NodeConfig(int port) => ConfigurationFactory.ParseString($$"""
akka {
loglevel = "WARNING"
actor.provider = "Akka.Cluster.ClusterActorRefProvider, Akka.Cluster"
remote.dot-netty.tcp {
hostname = "127.0.0.1"
port = {{port}}
}
cluster {
seed-nodes = ["akka.tcp://gossip-case-test@127.0.0.1:{{port}}"]
roles = ["Driver"]
min-nr-of-members = 1
run-coordinated-shutdown-when-down = off
downing-provider-class = ""
}
}
""");
private static async Task<ActorSystem> StartSingleNodeAsync()
{
var system = ActorSystem.Create("gossip-case-test", NodeConfig(Port));
var cluster = Akka.Cluster.Cluster.Get(system);
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30);
while (DateTime.UtcNow < deadline)
{
if (cluster.State.Members.Any(m => m.Status == MemberStatus.Up)) return system;
await Task.Delay(100);
}
await system.Terminate();
throw new TimeoutException("node never came Up");
}
[Fact]
public async Task A_member_gossiping_a_miscased_driver_role_classifies_Secondary_not_Detached()
{
// Pins the fix that IS achievable: BuildSnapshot's own role classification (member.Roles
// Contains -> GossipedRolesHave) no longer reads a genuinely-driver-but-miscased member as
// Detached, even though (see class remarks) it can never be named Primary by itself.
var system = await StartSingleNodeAsync();
try
{
var tcs = new TaskCompletionSource<RedundancyStateChanged>(
TaskCreationOptions.RunContinuationsAsynchronously);
system.ActorOf(
RedundancyStateActor.Props(broadcast: msg =>
{
if (msg is RedundancyStateChanged changed) tcs.TrySetResult(changed);
}),
$"redundancy-{Guid.NewGuid():N}");
var snapshot = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(15));
var node = snapshot.Nodes.ShouldHaveSingleItem();
node.Role.ShouldBe(
RedundancyRole.Secondary,
"gossiping 'Driver' must not read as Detached — it is a real driver member, just "
+ "miscased; BEFORE R3-04 this was Detached (ordinal Contains(\"driver\") on \"Driver\")");
}
finally
{
await system.Terminate();
}
}
/// <summary>
/// THE recorded deviation: proves the residual gap is real, not assumed. A pair (here,
/// trivially, a single node) that gossips ONLY 'Driver' still elects no Primary — because that
/// decision runs through Akka's own ordinal <c>Member.HasRole</c> inside the vendored
/// <c>ClusterActiveNode.OldestUpMember</c>, which this repo cannot patch. Tracked as a
/// follow-up against <c>ZB.MOM.WW.Health.Akka</c>, not closed by R3-04.
/// </summary>
[Fact]
public async Task Primary_election_itself_remains_ordinal_and_still_elects_no_one_for_an_all_miscased_pair()
{
var system = await StartSingleNodeAsync();
try
{
var members = Akka.Cluster.Cluster.Get(system).State.Members;
RedundancyStateActor.SelectOldestUpMemberOfRole(members, "driver").ShouldBeNull(
"known residual (R3-04 execution notes): SelectOldestUpMemberOfRole delegates to "
+ "ClusterActiveNode.OldestUpMember -> Akka's own ordinal Member.HasRole, which this "
+ "repo does not own — GossipedRolesHave cannot reach inside it");
}
finally
{
await system.Terminate();
}
}
[Fact]
public async Task ManualFailoverService_recognises_a_miscased_driver_node_as_a_candidate()
{
// FailOverCore refuses below 2 driver members (nothing to fail over TO), so this single-node
// fixture exercises GetSnapshot()'s PrimaryAddress instead — which shares the same
// OrderedDrivers candidate selection this test is actually about.
var system = await StartSingleNodeAsync();
try
{
var service = new ManualFailoverService(
() => system, new RecordingAuditWriter(), NullLogger<ManualFailoverService>.Instance);
var snapshot = service.GetSnapshot();
snapshot.PrimaryAddress.ShouldNotBeNull();
snapshot.PrimaryAddress!.ShouldContain(Port.ToString());
}
finally
{
await system.Terminate();
}
}
private sealed class RecordingAuditWriter : IAuditWriter
{
public Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default) =>
Task.CompletedTask;
}
}
@@ -0,0 +1,188 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
using ZB.MOM.WW.OtOpcUa.Security;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// R3-04 (01/S-12 = 03/C5): the exact shape the finding describes — a node configured ONLY
/// through <c>Cluster:Roles</c> (<c>OTOPCUA_ROLES</c> unset) — must wire the driver stack, not
/// the "no roles at all" host view a pre-fix <c>Program.cs</c> computed.
/// </summary>
/// <remarks>
/// <para>
/// Follows <see cref="DriverOnlyNoConfigDbBootTests"/>'s direct-host-build pattern
/// deliberately: <c>WebApplicationFactory&lt;Program&gt;</c> is not driven here because
/// starting the real host triggers the Akka cluster join and needs SQL Server (see
/// <c>TwoNodeClusterHarness</c>). What is under test is DI resolution off the SAME
/// composition Program.cs runs, so a container is built and resolved without starting it.
/// </para>
/// <para>
/// Unlike <see cref="DriverOnlyNoConfigDbBootTests"/>, <c>hasAdmin</c>/<c>hasDriver</c> are
/// NOT passed in as bool parameters here — they are derived from
/// <see cref="EffectiveRoles.Resolve(IConfiguration)"/> exactly as <c>Program.cs</c> (T3)
/// does, off a configuration carrying ONLY <c>Cluster:Roles</c>. The role DERIVATION is
/// therefore genuinely under test, not just the conditional wiring downstream of it.
/// </para>
/// </remarks>
public sealed class ClusterRolesOnlyBootTests : IDisposable
{
private readonly List<WebApplication> _apps = [];
private readonly List<string> _dbPaths = [];
/// <summary>
/// Builds the slice of <c>Program.cs</c>'s conditional wiring this finding is about: the
/// LocalDb registration (<c>:230</c>, inside <c>if (hasDriver)</c>) and the
/// <c>IOpcUaUserAuthenticator</c> selection (<c>:414-454</c>), driven by
/// <paramref name="deriveRoles"/> — the T3 (fixed) or pre-fix derivation, selected by the
/// caller so the two behaviours are provably different rather than assumed to be.
/// </summary>
private (IServiceProvider Services, bool HasAdmin, bool HasDriver) BuildGraph(
Dictionary<string, string?> configuration,
Func<IConfiguration, (bool HasAdmin, bool HasDriver)> deriveRoles)
{
var dbPath = Path.Combine(Path.GetTempPath(), $"otopcua-r304-boot-{Guid.NewGuid():N}.db");
_dbPaths.Add(dbPath);
configuration["LocalDb:Path"] = dbPath;
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] });
builder.Configuration.AddInMemoryCollection(configuration);
builder.Services.AddLogging();
var (hasAdmin, hasDriver) = deriveRoles(builder.Configuration);
builder.Services.AddOtOpcUaCluster(builder.Configuration);
// Program.cs:230 — driver-role only.
if (hasDriver)
builder.Services.AddOtOpcUaLocalDb(builder.Configuration);
// Program.cs:414-454 — the IOpcUaUserAuthenticator selection block, reproduced verbatim
// (the hasDriver-only leg only — hasAdmin needs a ConfigDb this test does not configure).
if (hasDriver)
{
builder.Services.AddSingleton<DataPlaneAuthorityState>();
builder.Services.AddSingleton<IDataPlaneAuthoritySource>(
sp => sp.GetRequiredService<DataPlaneAuthorityState>());
builder.Services.AddSingleton<IApiKeyStore, ArtifactApiKeyStore>();
builder.Services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
}
else
{
builder.Services.AddSingleton<IOpcUaUserAuthenticator>(NullOpcUaUserAuthenticator.Instance);
}
builder.Services.Configure<ApiKeyOptions>(o => o.Pepper = "test-pepper");
var app = builder.Build();
_apps.Add(app);
return (app.Services, hasAdmin, hasDriver);
}
/// <summary>T3's fix: resolve through the single role authority.</summary>
private static (bool HasAdmin, bool HasDriver) FixedDerivation(IConfiguration configuration)
{
var roles = EffectiveRoles.Resolve(configuration);
return (roles.HasAdmin, roles.HasDriver);
}
/// <summary>
/// The pre-T3 derivation — <c>Program.cs:61-63</c> before this plan, verbatim: reads
/// <c>OTOPCUA_ROLES</c> only, never <c>Cluster:Roles</c>. Takes <see cref="IConfiguration"/>
/// purely to match <see cref="FixedDerivation"/>'s signature; it does not use it.
/// </summary>
private static (bool HasAdmin, bool HasDriver) PreFixDerivation(IConfiguration _)
{
var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
return (roles.Contains("admin"), roles.Contains("driver"));
}
[Fact]
public void A_ClusterRolesOnly_node_wires_the_driver_stack()
{
var previous = Environment.GetEnvironmentVariable("OTOPCUA_ROLES");
Environment.SetEnvironmentVariable("OTOPCUA_ROLES", null);
try
{
var (services, hasAdmin, hasDriver) = BuildGraph(
new Dictionary<string, string?> { ["Cluster:Roles:0"] = "driver" },
FixedDerivation);
hasAdmin.ShouldBeFalse();
hasDriver.ShouldBeTrue();
services.GetService<ILocalDb>().ShouldNotBeNull(
"a Cluster:Roles-only driver node must have LocalDb wired");
services.GetRequiredService<IOpcUaUserAuthenticator>().ShouldBeOfType<ApiKeyOpcUaUserAuthenticator>(
"a Cluster:Roles-only driver node must authenticate api keys, not deny everything");
var akkaOptions = services.GetRequiredService<IOptions<AkkaClusterOptions>>().Value;
akkaOptions.Roles.ShouldBe(new[] { "driver" });
}
finally
{
Environment.SetEnvironmentVariable("OTOPCUA_ROLES", previous);
}
}
/// <summary>
/// THE negative control the plan requires: the SAME <c>Cluster:Roles</c>-only configuration,
/// run through the derivation Program.cs used BEFORE T3, must reproduce exactly the outage
/// 01/S-12 describes — no LocalDb, and every UserName token denied. Proves the fixed test
/// above is asserting a real behaviour change, not a tautology.
/// </summary>
[Fact]
public void The_same_configuration_through_the_preFix_derivation_gets_no_driver_stack_at_all()
{
var previous = Environment.GetEnvironmentVariable("OTOPCUA_ROLES");
Environment.SetEnvironmentVariable("OTOPCUA_ROLES", null);
try
{
var (services, hasAdmin, hasDriver) = BuildGraph(
new Dictionary<string, string?> { ["Cluster:Roles:0"] = "driver" },
PreFixDerivation);
hasAdmin.ShouldBeFalse();
hasDriver.ShouldBeFalse(
"the pre-fix derivation reads OTOPCUA_ROLES ONLY — Cluster:Roles=[\"driver\"] must be invisible to it");
services.GetService<ILocalDb>().ShouldBeNull(
"the pre-fix host view has no roles at all, so LocalDb is never registered");
services.GetRequiredService<IOpcUaUserAuthenticator>().ShouldBeOfType<NullOpcUaUserAuthenticator>(
"the pre-fix host view denies every UserName token — the exact outage 01/S-12 describes");
// The Akka side is unaffected by which derivation Program.cs used — AddOtOpcUaCluster (T2)
// always resolves Cluster:Roles itself, so even the pre-fix HOST view's blindness left the
// AKKA gossip correct. This is the two-authority split the finding is named for: Akka said
// "driver", Program.cs's conditional wiring said nothing.
var akkaOptions = services.GetRequiredService<IOptions<AkkaClusterOptions>>().Value;
akkaOptions.Roles.ShouldBe(new[] { "driver" });
}
finally
{
Environment.SetEnvironmentVariable("OTOPCUA_ROLES", previous);
}
}
public void Dispose()
{
foreach (var app in _apps)
((IDisposable)app).Dispose();
foreach (var path in _dbPaths)
{
try { if (File.Exists(path)) File.Delete(path); }
catch { /* best-effort cleanup */ }
}
}
}