Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/AkkaClusterOptionsValidatorTests.cs
T
Joseph Doherty af39e61638 fix(cluster): T7's post-overlay guard was structurally unreachable — compare live Cluster:Roles, not the PostConfigured options.Roles (review finding, R3-04)
options.Roles is unconditionally overwritten by AddOtOpcUaCluster's PostConfigure to equal
EffectiveRoles.Gossiped (T2's own structural-identity fix), so comparing options.Roles against
Gossiped could never observe a divergence — an overlay setting Cluster:Roles was silently
ignored, not fail-start. The validator now injects IConfiguration and re-reads the raw
Cluster:Roles section LIVE (reflecting any overlay applied after EffectiveRoles was first
resolved), normalized through EffectiveRoles.Resolve for reuse of its own validation.

AddOtOpcUaCluster now TryAddSingleton(configuration) so a bare ServiceCollection test harness
(not just WebApplicationBuilder) can supply IConfiguration to the validator.

Both guard tests rewritten to go through the real AddOtOpcUaCluster + PostConfigure pipeline
over a live ConfigurationManager, simulating an overlay as a second provider added AFTER
EffectiveRoles was resolved off the same object — exactly Program.cs's own sequencing. Confirmed
red beforehand: the "changes" test failed to throw against the pre-fix validator.
2026-08-20 11:01:33 -04:00

345 lines
15 KiB
C#

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
/// <summary>
/// Guards the self-first seed-ordering rule at the only moment it can still be fixed cheaply: boot.
/// </summary>
/// <remarks>
/// <para>
/// The invariant (decision 2026-07-22) is <b>conditional</b>: <i>if</i> a node's own address
/// appears in its own <see cref="AkkaClusterOptions.SeedNodes"/>, it must be entry 0. Akka
/// runs <c>FirstSeedNodeProcess</c> — the only path that can form a NEW cluster when no peer
/// answers <c>InitJoin</c> — exclusively for <c>seed-nodes[0]</c>; every other node retries
/// <c>InitJoin</c> forever. A node listed second therefore cannot cold-start while its peer
/// is down, and it fails <i>silently</i>: the process is healthy, the port is open, the node
/// simply never becomes a cluster member. That is why the rule is enforced loudly here.
/// </para>
/// <para>
/// The conditional form is required, not incidental: a flat "self must be first" rule would
/// reject every driver-only site node, which is seeded solely by <c>central-1</c> and is
/// legitimately not a seed of anything —
/// see <see cref="Node_absent_from_its_own_seed_list_is_exempt"/>.
/// </para>
/// </remarks>
public sealed class AkkaClusterOptionsValidatorTests
{
private static AkkaClusterOptions CentralNode(string publicHostname, params string[] seeds) =>
new()
{
// The docker-dev shape: bind to every interface, advertise the container DNS name.
Hostname = "0.0.0.0",
PublicHostname = publicHostname,
Port = 4053,
Roles = new[] { "admin", "driver" },
SeedNodes = seeds,
};
private static string Seed(string host, int port = 4053) => $"akka.tcp://otopcua@{host}:{port}";
/// <summary>The shipped central-1 / central-2 shape: own address first, partner second.</summary>
[Fact]
public void Self_first_seed_order_passes()
{
var options = CentralNode("central-2", Seed("central-2"), Seed("central-1"));
var result = new AkkaClusterOptionsValidator().Validate(null, options);
result.Failed.ShouldBeFalse(result.FailureMessage);
}
/// <summary>
/// The pre-2026-07-22 ordering — partner first — is the boot-alone outage gap, and must not
/// start. This is the shape <c>docker-dev</c>'s <c>central-2</c> carried.
/// </summary>
[Fact]
public void Peer_first_seed_order_fails()
{
var options = CentralNode("central-2", Seed("central-1"), Seed("central-2"));
var result = new AkkaClusterOptionsValidator().Validate(null, options);
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldNotBeNull().ShouldContain("SeedNodes");
result.FailureMessage.ShouldContain("must list this node itself first");
}
/// <summary>
/// THE conditional exemption. A driver-only site node lists only <c>central-1</c> — it is not a
/// seed at all, is never legitimately first, and must pass. An unconditional "self must be
/// first" rule would refuse to boot every site node in the fleet.
/// </summary>
[Fact]
public void Node_absent_from_its_own_seed_list_is_exempt()
{
var options = CentralNode("site-a-1", Seed("central-1"));
var result = new AkkaClusterOptionsValidator().Validate(null, options);
result.Failed.ShouldBeFalse(result.FailureMessage);
}
/// <summary>
/// The bind address is NOT the node's identity. In docker-dev <c>Cluster:Hostname</c> is
/// <c>0.0.0.0</c> and <c>Cluster:PublicHostname</c> is the container name — the value Akka puts
/// in <c>SelfAddress</c> and therefore the only one a seed entry can match. A validator that
/// compared against <c>Hostname</c> would find no match anywhere, silently exempt every node,
/// and pass this misordered config.
/// </summary>
[Fact]
public void Identity_is_the_public_hostname_not_the_bind_address()
{
var options = CentralNode("central-2", Seed("central-1"), Seed("central-2"));
options.Hostname.ShouldBe("0.0.0.0", "the trap only exists when the bind address is a wildcard");
var result = new AkkaClusterOptionsValidator().Validate(null, options);
result.Failed.ShouldBeTrue("matching on the 0.0.0.0 bind address would make this rule vacuous");
}
/// <summary>
/// With <c>PublicHostname</c> blank Akka advertises the bind hostname, so that is the identity to
/// match — otherwise a loopback/bare-metal pair configured that way would be silently exempt.
/// </summary>
[Fact]
public void Identity_falls_back_to_the_bind_hostname_when_no_public_hostname_is_set()
{
var options = CentralNode("central-2", Seed("node-a"), Seed("node-b"));
options.Hostname = "node-b";
options.PublicHostname = string.Empty;
var result = new AkkaClusterOptionsValidator().Validate(null, options);
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldNotBeNull().ShouldContain("must list this node itself first");
}
/// <summary>
/// Host AND port: a two-node install sharing one hostname and differing only by port (a
/// loopback/dev pair) is a real topology, and matching on host alone would clear the misordered
/// node.
/// </summary>
[Fact]
public void Self_match_compares_host_and_port()
{
var options = CentralNode("127.0.0.1", Seed("127.0.0.1", 4053), Seed("127.0.0.1", 4054));
options.Port = 4054;
var result = new AkkaClusterOptionsValidator().Validate(null, options);
result.Failed.ShouldBeTrue("this node is 127.0.0.1:4054, which is the SECOND seed");
}
/// <summary>
/// Positive control for the pair above: the same host on the same port really is a match, so the
/// port comparison cannot be passing merely by never matching anything.
/// </summary>
[Fact]
public void Self_match_on_the_same_host_and_port_passes()
{
var options = CentralNode("127.0.0.1", Seed("127.0.0.1", 4054), Seed("127.0.0.1", 4053));
options.Port = 4054;
var result = new AkkaClusterOptionsValidator().Validate(null, options);
result.Failed.ShouldBeFalse(result.FailureMessage);
}
/// <summary>
/// An empty seed list is the single-node/dev default and says nothing about ordering — the rule
/// has nothing to bind to and must not invent a failure.
/// </summary>
[Fact]
public void Empty_seed_list_passes()
{
var options = CentralNode("central-1");
var result = new AkkaClusterOptionsValidator().Validate(null, options);
result.Failed.ShouldBeFalse(result.FailureMessage);
}
/// <summary>
/// A malformed seed URI is Akka's error to report, with Akka's wording. This rule must not
/// crash on it, and must not turn a parse problem into a misleading "ordering" complaint —
/// here the ordering is correct and only a later entry is unparseable.
/// </summary>
[Fact]
public void Malformed_seed_entry_does_not_throw()
{
var options = CentralNode("central-1", Seed("central-1"), "not-an-akka-address");
var result = new AkkaClusterOptionsValidator().Validate(null, options);
result.Failed.ShouldBeFalse(result.FailureMessage);
}
/// <summary>The bootstrap-guard timing knobs default to positive values and pass when enabled.</summary>
[Fact]
public void Bootstrap_guard_defaults_pass()
{
var options = CentralNode("site-a-1", Seed("site-a-1"), Seed("site-a-2"));
options.BootstrapGuard = new ClusterBootstrapGuardOptions { Enabled = true };
var result = new AkkaClusterOptionsValidator().Validate(null, options);
result.Failed.ShouldBeFalse(result.FailureMessage);
}
/// <summary>
/// A zero <c>PartnerProbeSeconds</c> silently degrades the guard to "never wait, form alone
/// immediately" — re-opening the split. It must fail the host at boot, not run degraded.
/// </summary>
[Fact]
public void Bootstrap_guard_with_non_positive_probe_seconds_fails()
{
var options = CentralNode("site-a-1", Seed("site-a-1"), Seed("site-a-2"));
options.BootstrapGuard = new ClusterBootstrapGuardOptions { Enabled = true, PartnerProbeSeconds = 0 };
var result = new AkkaClusterOptionsValidator().Validate(null, options);
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldContain("PartnerProbeSeconds");
}
/// <summary>The timing knobs are NOT validated when the guard is off — a disabled guard is inert.</summary>
[Fact]
public void Bootstrap_guard_disabled_does_not_validate_timings()
{
var options = CentralNode("site-a-1", Seed("site-a-1"), Seed("site-a-2"));
options.BootstrapGuard = new ClusterBootstrapGuardOptions { Enabled = false, PartnerProbeSeconds = 0 };
var result = new AkkaClusterOptionsValidator().Validate(null, options);
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);
}
}