diff --git a/archreview/plans/R3-04-one-role-authority-plan.md.tasks.json b/archreview/plans/R3-04-one-role-authority-plan.md.tasks.json index 86cb2627..76724e15 100644 --- a/archreview/plans/R3-04-one-role-authority-plan.md.tasks.json +++ b/archreview/plans/R3-04-one-role-authority-plan.md.tasks.json @@ -69,7 +69,7 @@ { "id": "T9", "subject": "ClusterRolesOnlyBootTests: a Cluster:Roles-only node wires the driver stack (with a pre-fix negative control)", - "status": "pending", + "status": "completed", "blockedBy": [ "T3" ] diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ClusterRolesOnlyBootTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ClusterRolesOnlyBootTests.cs new file mode 100644 index 00000000..acff9d25 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ClusterRolesOnlyBootTests.cs @@ -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; + +/// +/// R3-04 (01/S-12 = 03/C5): the exact shape the finding describes — a node configured ONLY +/// through Cluster:Roles (OTOPCUA_ROLES unset) — must wire the driver stack, not +/// the "no roles at all" host view a pre-fix Program.cs computed. +/// +/// +/// +/// Follows 's direct-host-build pattern +/// deliberately: WebApplicationFactory<Program> is not driven here because +/// starting the real host triggers the Akka cluster join and needs SQL Server (see +/// TwoNodeClusterHarness). What is under test is DI resolution off the SAME +/// composition Program.cs runs, so a container is built and resolved without starting it. +/// +/// +/// Unlike , hasAdmin/hasDriver are +/// NOT passed in as bool parameters here — they are derived from +/// exactly as Program.cs (T3) +/// does, off a configuration carrying ONLY Cluster:Roles. The role DERIVATION is +/// therefore genuinely under test, not just the conditional wiring downstream of it. +/// +/// +public sealed class ClusterRolesOnlyBootTests : IDisposable +{ + private readonly List _apps = []; + private readonly List _dbPaths = []; + + /// + /// Builds the slice of Program.cs's conditional wiring this finding is about: the + /// LocalDb registration (:230, inside if (hasDriver)) and the + /// IOpcUaUserAuthenticator selection (:414-454), driven by + /// — the T3 (fixed) or pre-fix derivation, selected by the + /// caller so the two behaviours are provably different rather than assumed to be. + /// + private (IServiceProvider Services, bool HasAdmin, bool HasDriver) BuildGraph( + Dictionary configuration, + Func 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(); + builder.Services.AddSingleton( + sp => sp.GetRequiredService()); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + } + else + { + builder.Services.AddSingleton(NullOpcUaUserAuthenticator.Instance); + } + builder.Services.Configure(o => o.Pepper = "test-pepper"); + + var app = builder.Build(); + _apps.Add(app); + return (app.Services, hasAdmin, hasDriver); + } + + /// T3's fix: resolve through the single role authority. + private static (bool HasAdmin, bool HasDriver) FixedDerivation(IConfiguration configuration) + { + var roles = EffectiveRoles.Resolve(configuration); + return (roles.HasAdmin, roles.HasDriver); + } + + /// + /// The pre-T3 derivation — Program.cs:61-63 before this plan, verbatim: reads + /// OTOPCUA_ROLES only, never Cluster:Roles. Takes + /// purely to match 's signature; it does not use it. + /// + 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 { ["Cluster:Roles:0"] = "driver" }, + FixedDerivation); + + hasAdmin.ShouldBeFalse(); + hasDriver.ShouldBeTrue(); + + services.GetService().ShouldNotBeNull( + "a Cluster:Roles-only driver node must have LocalDb wired"); + services.GetRequiredService().ShouldBeOfType( + "a Cluster:Roles-only driver node must authenticate api keys, not deny everything"); + + var akkaOptions = services.GetRequiredService>().Value; + akkaOptions.Roles.ShouldBe(new[] { "driver" }); + } + finally + { + Environment.SetEnvironmentVariable("OTOPCUA_ROLES", previous); + } + } + + /// + /// THE negative control the plan requires: the SAME Cluster:Roles-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. + /// + [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 { ["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().ShouldBeNull( + "the pre-fix host view has no roles at all, so LocalDb is never registered"); + services.GetRequiredService().ShouldBeOfType( + "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>().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 */ } + } + } +}