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(); } }