using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
///
/// Per-cluster mesh Phase 4 — ConfigDb is registered iff the node has the admin role.
/// A driver-only node (Akka role driver, NOT admin) boots with NO ConfigDb
/// connection string at all; its configuration arrives from central via the Phase-3
/// ConfigSource:Mode=FetchAndCache path.
///
///
///
/// These tests replicate the exact ConfigDb gating decision Program.cs makes —
/// if (hasAdmin) services.AddOtOpcUaConfigDb(configuration) — over the real
/// method, for both role shapes.
/// They deliberately do NOT boot the full host: Program.cs reads roles from the
/// OTOPCUA_ROLES process env var and starting the host triggers the Akka cluster join
/// (see TwoNodeClusterHarness on why WebApplicationFactory<Program> is not
/// driven here, and LocalDbWiringTests for the same partial-graph harness model). The
/// full driver-only boot is proven by the Phase-4 live gate + the later DI-graph sweep task.
///
///
public sealed class DriverOnlyNoConfigDbBootTests : IDisposable
{
private readonly List _apps = [];
///
/// Builds the shared-services graph the way Program.cs gates ConfigDb: it is registered iff
/// . A driver-only node passes hasAdmin: false and supplies
/// no ConnectionStrings:ConfigDb at all.
///
private IServiceProvider BuildSharedServicesGraph(bool hasAdmin, string? configDbConnectionString)
{
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] });
if (configDbConnectionString is not null)
{
builder.Configuration.AddInMemoryCollection(new Dictionary
{
["ConnectionStrings:ConfigDb"] = configDbConnectionString,
});
}
if (hasAdmin)
builder.Services.AddOtOpcUaConfigDb(builder.Configuration);
var app = builder.Build();
_apps.Add(app);
return app.Services;
}
[Fact]
public void DriverOnlyNode_NoConnectionString_BuildsWithoutThrowing_AndRegistersNoConfigDbFactory()
{
// The core Phase-4 guarantee: a driver-only node holds no ConfigDb connection string and must
// still build its service graph without throwing — nothing in the shared registrations may
// reach for the ConfigDb factory.
IServiceProvider sp = null!;
Should.NotThrow(() => sp = BuildSharedServicesGraph(hasAdmin: false, configDbConnectionString: null));
sp.GetService>().ShouldBeNull();
}
[Fact]
public void AdminNode_WithConnectionString_RegistersTheConfigDbFactory()
{
// The fused/admin path is byte-for-byte unaffected: ConfigDb is registered exactly as before.
var sp = BuildSharedServicesGraph(
hasAdmin: true,
configDbConnectionString: "Server=(local);Database=OtOpcUa;Trusted_Connection=True;TrustServerCertificate=True");
sp.GetService>().ShouldNotBeNull();
}
[Fact]
public void AddOtOpcUaConfigDb_WithoutConnectionString_Throws()
{
// Why the gate exists: the unconditional call Program.cs made throws on a driver-only node,
// which has no ConfigDb connection string. This pins the exact failure the hasAdmin gate avoids.
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] });
Should.Throw(() => builder.Services.AddOtOpcUaConfigDb(builder.Configuration));
}
///
/// Replicates Program.cs's role-mapping DI ordering, top to bottom:
///
/// -
/// the hasAdmin block — AddOtOpcUaConfigDb, which plain-AddScopeds
/// the real (line ~132 in Program.cs);
///
/// -
/// AddValidatedOptions<LdapOptions, LdapOptionsValidator>, registered
/// unconditionally for every role shape (line ~143);
///
/// -
/// the hasDriver block's three TryAdd registrations (lines ~333-335):
/// , .
///
///
/// On a FUSED node this ordering is exactly what lets the real EF-backed service win the
/// TryAdd — a plain AddScoped that ran first already claimed the descriptor slot,
/// so the later TryAddScoped is a no-op. Nothing enforces that order beyond Program.cs's
/// own top-to-bottom script. Verified by locally inverting the ordering to (3)-then-(1) AND
/// switching AddOtOpcUaConfigDb's ILdapGroupRoleMappingService registration to a
/// TryAddScoped (both reverted before commit) — that combination is what flips the
/// resolved type to and turns this test red;
/// either change alone stays green today (an unconditional AddScoped always wins
/// last-registered regardless of order, and a TryAdd that runs first still wins). Security:
/// Ldap:Enabled is forced false here purely so doesn't demand
/// a live Server/SearchBase/Transport — it is orthogonal to what is under test.
///
private IServiceProvider BuildRoleMappingGraph(bool hasAdmin, string? configDbConnectionString)
{
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] });
var config = new Dictionary { ["Security:Ldap:Enabled"] = "false" };
if (configDbConnectionString is not null)
config["ConnectionStrings:ConfigDb"] = configDbConnectionString;
builder.Configuration.AddInMemoryCollection(config);
// (1) hasAdmin block.
if (hasAdmin)
builder.Services.AddOtOpcUaConfigDb(builder.Configuration);
// (2) unconditional for every role shape.
builder.Services.AddValidatedOptions(
builder.Configuration, LdapOptions.SectionName);
// (3) hasDriver block's TryAdd registrations — runs AFTER (1), never before it.
builder.Services.TryAddScoped();
builder.Services.TryAddScoped, OtOpcUaGroupRoleMapper>();
var app = builder.Build();
_apps.Add(app);
return app.Services;
}
[Fact]
public void FusedNode_RealLdapGroupRoleMappingService_WinsOverTheDriverBlockTryAdd()
{
// The Task 1b review gap: on a fused admin+driver node, AddOtOpcUaConfigDb's plain AddScoped
// registers the real EF-backed service BEFORE the hasDriver block's TryAddScoped runs, so the
// TryAdd is a no-op and the real service wins. If AddOtOpcUaConfigDb's registration were ever
// switched to a TryAdd, or the two blocks reordered, this flips to NullLdapGroupRoleMappingService
// and every DB-backed role grant central relies on silently stops applying — with no other test
// catching it.
var sp = BuildRoleMappingGraph(
hasAdmin: true,
configDbConnectionString: "Server=(local);Database=OtOpcUa;Trusted_Connection=True;TrustServerCertificate=True");
using var scope = sp.CreateScope();
scope.ServiceProvider.GetRequiredService()
.ShouldBeOfType();
}
[Fact]
public void DriverOnlyNode_LdapGroupRoleMapping_ResolvesTheNullImpl_AndNoConfigDbFactory()
{
// The other half of the same pin: with no hasAdmin block ever run, the driver block's TryAdd
// is the ONLY registration, so it wins by default — no ConfigDb, no real EF service reachable.
var sp = BuildRoleMappingGraph(hasAdmin: false, configDbConnectionString: null);
sp.GetService>().ShouldBeNull();
using var scope = sp.CreateScope();
scope.ServiceProvider.GetRequiredService()
.ShouldBeOfType();
}
[Fact]
public async Task DriverOnlyNode_IGroupRoleMapper_ResolvesInScope_AndMapsWithoutThrowing()
{
// Task 1b fixed a lazy auth-path break: IGroupRoleMapper depends on
// ILdapGroupRoleMappingService, which a driver-only node never registered before that fix —
// the DI graph built fine (nothing eager touches it), and the break surfaced only on the first
// SCOPED resolve + call, deep in the OPC UA data-plane authenticator. A bare Build() cannot
// catch that class of bug; this test resolves in a scope and calls MapAsync, mirroring the
// real call site.
var sp = BuildRoleMappingGraph(hasAdmin: false, configDbConnectionString: null);
using var scope = sp.CreateScope();
var mapper = scope.ServiceProvider.GetRequiredService>();
mapper.ShouldBeOfType();
scope.ServiceProvider.GetRequiredService()
.ShouldBeOfType();
GroupRoleMapping result = null!;
await Should.NotThrowAsync(async () => result = await mapper.MapAsync(["some-group"], CancellationToken.None));
result.ShouldNotBeNull();
result.Scope.ShouldBeNull();
}
public void Dispose()
{
foreach (var app in _apps)
((IDisposable)app).Dispose();
}
}