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