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