diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverOnlyNoConfigDbBootTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverOnlyNoConfigDbBootTests.cs index 954ddadc..c74d27b5 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverOnlyNoConfigDbBootTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverOnlyNoConfigDbBootTests.cs @@ -2,9 +2,15 @@ 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; @@ -87,6 +93,115 @@ public sealed class DriverOnlyNoConfigDbBootTests : IDisposable 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)