test(mesh-phase4): pin driver-only ConfigDb-free DI graph + fused-node mapper ordering
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
+115
@@ -2,9 +2,15 @@ using Microsoft.AspNetCore.Builder;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
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;
|
||||||
|
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;
|
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||||
|
|
||||||
@@ -87,6 +93,115 @@ public sealed class DriverOnlyNoConfigDbBootTests : IDisposable
|
|||||||
Should.Throw<InvalidOperationException>(() => builder.Services.AddOtOpcUaConfigDb(builder.Configuration));
|
Should.Throw<InvalidOperationException>(() => builder.Services.AddOtOpcUaConfigDb(builder.Configuration));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replicates Program.cs's role-mapping DI ordering, top to bottom:
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item><description>
|
||||||
|
/// the <c>hasAdmin</c> block — <c>AddOtOpcUaConfigDb</c>, which plain-<c>AddScoped</c>s
|
||||||
|
/// the real <see cref="LdapGroupRoleMappingService"/> (line ~132 in Program.cs);
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// <c>AddValidatedOptions<LdapOptions, LdapOptionsValidator></c>, registered
|
||||||
|
/// unconditionally for every role shape (line ~143);
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// the <c>hasDriver</c> block's three <c>TryAdd</c> registrations (lines ~333-335):
|
||||||
|
/// <see cref="NullLdapGroupRoleMappingService"/>, <see cref="OtOpcUaGroupRoleMapper"/>.
|
||||||
|
/// </description></item>
|
||||||
|
/// </list>
|
||||||
|
/// On a FUSED node this ordering is exactly what lets the real EF-backed service win the
|
||||||
|
/// <c>TryAdd</c> — a plain <c>AddScoped</c> that ran first already claimed the descriptor slot,
|
||||||
|
/// so the later <c>TryAddScoped</c> 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 <c>AddOtOpcUaConfigDb</c>'s <c>ILdapGroupRoleMappingService</c> registration to a
|
||||||
|
/// <c>TryAddScoped</c> (both reverted before commit) — that combination is what flips the
|
||||||
|
/// resolved type to <see cref="NullLdapGroupRoleMappingService"/> and turns this test red;
|
||||||
|
/// either change alone stays green today (an unconditional <c>AddScoped</c> always wins
|
||||||
|
/// last-registered regardless of order, and a <c>TryAdd</c> that runs first still wins). Security:
|
||||||
|
/// Ldap:Enabled is forced false here purely so <see cref="LdapOptionsValidator"/> doesn't demand
|
||||||
|
/// a live Server/SearchBase/Transport — it is orthogonal to what is under test.
|
||||||
|
/// </summary>
|
||||||
|
private IServiceProvider BuildRoleMappingGraph(bool hasAdmin, string? configDbConnectionString)
|
||||||
|
{
|
||||||
|
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] });
|
||||||
|
var config = new Dictionary<string, string?> { ["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<LdapOptions, LdapOptionsValidator>(
|
||||||
|
builder.Configuration, LdapOptions.SectionName);
|
||||||
|
|
||||||
|
// (3) hasDriver block's TryAdd registrations — runs AFTER (1), never before it.
|
||||||
|
builder.Services.TryAddScoped<ILdapGroupRoleMappingService, NullLdapGroupRoleMappingService>();
|
||||||
|
builder.Services.TryAddScoped<IGroupRoleMapper<string>, 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<ILdapGroupRoleMappingService>()
|
||||||
|
.ShouldBeOfType<LdapGroupRoleMappingService>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<IDbContextFactory<OtOpcUaConfigDbContext>>().ShouldBeNull();
|
||||||
|
|
||||||
|
using var scope = sp.CreateScope();
|
||||||
|
scope.ServiceProvider.GetRequiredService<ILdapGroupRoleMappingService>()
|
||||||
|
.ShouldBeOfType<NullLdapGroupRoleMappingService>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DriverOnlyNode_IGroupRoleMapper_ResolvesInScope_AndMapsWithoutThrowing()
|
||||||
|
{
|
||||||
|
// Task 1b fixed a lazy auth-path break: IGroupRoleMapper<string> 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<IGroupRoleMapper<string>>();
|
||||||
|
mapper.ShouldBeOfType<OtOpcUaGroupRoleMapper>();
|
||||||
|
scope.ServiceProvider.GetRequiredService<ILdapGroupRoleMappingService>()
|
||||||
|
.ShouldBeOfType<NullLdapGroupRoleMappingService>();
|
||||||
|
|
||||||
|
GroupRoleMapping<string> result = null!;
|
||||||
|
await Should.NotThrowAsync(async () => result = await mapper.MapAsync(["some-group"], CancellationToken.None));
|
||||||
|
|
||||||
|
result.ShouldNotBeNull();
|
||||||
|
result.Scope.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
foreach (var app in _apps)
|
foreach (var app in _apps)
|
||||||
|
|||||||
Reference in New Issue
Block a user