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.
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
@@ -220,32 +223,90 @@ public sealed class AkkaClusterOptionsValidatorTests
|
||||
// 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.
|
||||
|
||||
[Fact]
|
||||
public void A_config_layer_that_changes_Cluster_Roles_after_resolution_fails_naming_both_views()
|
||||
/// <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 effectiveRoles = EffectiveRoles.Resolve(["driver"], null);
|
||||
var options = CentralNode("site-a-1", Seed("site-a-1"));
|
||||
options.Roles = new[] { "admin", "driver" }; // the post-overlay bound value disagrees
|
||||
var configuration = new ConfigurationManager();
|
||||
configuration.AddInMemoryCollection(baseConfig);
|
||||
|
||||
var result = new AkkaClusterOptionsValidator(effectiveRoles).Validate(null, options);
|
||||
// Program.cs:61-63 (T3) — resolved BEFORE the overlay loads, off the pre-overlay snapshot.
|
||||
var effectiveRoles = EffectiveRoles.Resolve(configuration);
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldContain("Cluster:Roles");
|
||||
result.FailureMessage.ShouldContain("driver");
|
||||
result.FailureMessage.ShouldContain("admin");
|
||||
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 Bound_Roles_matching_the_registered_EffectiveRoles_passes()
|
||||
public void An_overlay_that_changes_Cluster_Roles_after_resolution_fails_naming_both_views()
|
||||
{
|
||||
var effectiveRoles = EffectiveRoles.Resolve(["driver", "cluster-SITE-A"], null);
|
||||
var options = CentralNode("site-a-1", Seed("site-a-1"));
|
||||
options.Roles = new[] { "driver", "cluster-SITE-A" };
|
||||
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 result = new AkkaClusterOptionsValidator(effectiveRoles).Validate(null, options);
|
||||
var ex = Should.Throw<OptionsValidationException>(
|
||||
() => provider.GetRequiredService<IOptions<AkkaClusterOptions>>().Value);
|
||||
|
||||
result.Failed.ShouldBeFalse(result.FailureMessage);
|
||||
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>
|
||||
@@ -263,4 +324,21 @@ public sealed class AkkaClusterOptionsValidatorTests
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user