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 */ } } } }