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:
@@ -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]
|
||||
|
||||
+220
@@ -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<Program></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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user