using Microsoft.AspNetCore.Components.Authorization; using ZB.MOM.WW.ScadaBridge.CentralUI.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NSubstitute; using ZB.MOM.WW.Auth.Abstractions.Ldap; using ZB.MOM.WW.Auth.Abstractions.Roles; using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth; public class CircuitRevocationRegressionTests { /// Verifies that an explicit server auto-login does not remap the synthetic cookie principal. /// A task that represents the asynchronous operation. [Fact] public async Task ExplicitServerAutoLogin_DoesNotRemapSyntheticCookiePrincipal() { var initial = SessionClaimBuilder.Build("developer", "Developer", [], new RoleMappingResult(Roles.All, [], true), DateTimeOffset.UtcNow); var mapper = Substitute.For>(); mapper.MapAsync(Arg.Any>(), Arg.Any()) .Returns(new GroupRoleMapping([], Scope: new RoleMappingResult([], [], false))); var services = new ServiceCollection().AddLogging(); services.AddSingleton(mapper); services.AddSingleton(TimeProvider.System); services.AddSingleton(Options.Create(new SecurityOptions())); services.AddSingleton(Options.Create(new ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions { DisableLogin = true })); services.AddScoped(); services.AddSingleton(new HttpContextAccessor { HttpContext = new DefaultHttpContext { User = initial } }); services.AddScoped(); using var provider = services.BuildServiceProvider(); using var scope = provider.CreateScope(); var auth = scope.ServiceProvider.GetRequiredService(); var state = await auth.GetAuthoritativeStateAsync(); Assert.Contains(state.User.Claims, claim => claim.Type == JwtTokenService.RoleClaimType && claim.Value == Roles.Administrator); await mapper.DidNotReceive().MapAsync(Arg.Any>(), Arg.Any()); } /// Verifies that the same circuit rechecks role mapping and site scope after a grant is revoked. /// A task that represents the asynchronous operation. [Fact] public async Task SameCircuit_RechecksMappingAndScope_AfterGrantRevocation() { var mapping = new RoleMappingResult([Roles.Deployer, Roles.Administrator], ["1"], false); var mapper = Substitute.For>(); mapper.MapAsync(Arg.Any>(), Arg.Any()) .Returns(_ => new GroupRoleMapping(mapping.Roles, Scope: mapping)); var initial = SessionClaimBuilder.Build("alice", "Alice", ["group"], mapping, DateTimeOffset.UtcNow); var services = new ServiceCollection().AddLogging(); services.AddSingleton(mapper); var transaction = Substitute.For(); services.AddSingleton(transaction); services.AddScoped(); services.AddSingleton(TimeProvider.System); services.AddSingleton(Options.Create(new SecurityOptions())); services.AddScoped(); services.AddSingleton(new HttpContextAccessor { HttpContext = new DefaultHttpContext { User = initial } }); services.AddScoped(); using var provider = services.BuildServiceProvider(); using var scope = provider.CreateScope(); var auth = scope.ServiceProvider.GetRequiredService(); var sites = new SiteScopeService(auth); Assert.True(await sites.IsSiteAllowedAsync(1)); mapping = new RoleMappingResult([Roles.Viewer], ["2"], false); // Authoritative read: a non-authoritative read may reuse the circuit's last // revalidation for CookieAuthenticationStateProvider.NonAuthoritativeRefreshInterval // (review 2026-10-21 07/P-3); every gate below is authoritative. var refreshed = await auth.GetAuthoritativeStateAsync(); Assert.DoesNotContain(Roles.Deployer, refreshed.User.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value)); Assert.False(await sites.IsSiteAllowedAsync(1)); Assert.True(await sites.IsSiteAllowedAsync(2)); // The already-created circuit facade must deny before starting any save transaction. var commands = scope.ServiceProvider.GetRequiredService(); await Assert.ThrowsAsync(() => commands.ExecuteAsync(new CreateRoleMappingCommand("blocked", Roles.Viewer))); await transaction.DidNotReceive().ExecuteAsync(Arg.Any>>()); } // ---- ScadaBridge#46: directory group re-query reaches a live circuit ---- private static (AuthenticationStateProvider Auth, ILdapGroupLookup Lookup, ServiceProvider Provider) DirectoryCircuit( IReadOnlyList loginGroups, bool disableLogin = false) { // Session issued 16 minutes ago, so the 15-minute group re-query is due on the first read. var issued = DateTimeOffset.UtcNow.AddMinutes(-16); var initial = SessionClaimBuilder.Build("alice", "Alice", loginGroups, new RoleMappingResult([Roles.Administrator], [], true), issued); var mapper = Substitute.For>(); mapper.MapAsync(Arg.Any>(), Arg.Any()) .Returns(call => { var groups = call.Arg>(); var roles = groups.Contains("SCADA-Admins") ? new[] { Roles.Administrator } : new[] { Roles.Viewer }; return new GroupRoleMapping(roles, Scope: new RoleMappingResult(roles, [], true)); }); var lookup = Substitute.For(); var services = new ServiceCollection().AddLogging(); services.AddSingleton(mapper); services.AddSingleton(lookup); services.AddSingleton(TimeProvider.System); // Cache off, so "no second query" below is proven by the session's advanced anchor, not the cache. services.AddSingleton(Options.Create(new SecurityOptions { LdapGroupRefresh = { CacheSeconds = 0 } })); services.AddSingleton(Options.Create(new ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions { DisableLogin = disableLogin })); services.AddSingleton(); services.AddScoped(); services.AddSingleton(new HttpContextAccessor { HttpContext = new DefaultHttpContext { User = initial } }); services.AddScoped(); var provider = services.BuildServiceProvider(); return (provider.CreateScope().ServiceProvider.GetRequiredService(), lookup, provider); } /// A directory group removal lands in an open circuit without re-login (ScadaBridge#46). /// A task that represents the asynchronous operation. [Fact] public async Task SameCircuit_PicksUpDirectoryGroupRemoval_WithoutReLogin() { var (auth, lookup, provider) = DirectoryCircuit(["SCADA-Admins"]); using var _ = provider; lookup.GetUserGroupsAsync("alice", Arg.Any()) .Returns(LdapGroupLookupResult.Found("alice", "Alice", ["SCADA-Viewers"])); var state = await auth.GetAuthoritativeStateAsync(); var roles = state.User.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList(); Assert.Equal([Roles.Viewer], roles); Assert.True(state.User.Identity!.IsAuthenticated); await lookup.Received(1).GetUserGroupsAsync("alice", Arg.Any()); // The circuit's rebuilt principal carries the advanced anchor: the next read does not re-query. await auth.GetAuthoritativeStateAsync(); await lookup.Received(1).GetUserGroupsAsync("alice", Arg.Any()); } /// A user disabled in the directory loses the circuit's authentication; a gate then denies. /// A task that represents the asynchronous operation. [Fact] public async Task SameCircuit_DirectoryDisabledUser_BecomesAnonymous() { var (auth, lookup, provider) = DirectoryCircuit(["SCADA-Admins"]); using var _ = provider; lookup.GetUserGroupsAsync("alice", Arg.Any()) .Returns(LdapGroupLookupResult.Of(LdapGroupLookupStatus.UserDisabled)); var state = await auth.GetAuthoritativeStateAsync(); Assert.False(state.User.Identity?.IsAuthenticated ?? false); Assert.Empty(state.User.FindAll(JwtTokenService.RoleClaimType)); } /// An LDAP outage keeps the circuit's roles, even on the fail-closed authoritative path. /// A task that represents the asynchronous operation. [Fact] public async Task SameCircuit_DirectoryUnavailable_KeepsRoles() { var (auth, lookup, provider) = DirectoryCircuit(["SCADA-Admins"]); using var _ = provider; lookup.GetUserGroupsAsync("alice", Arg.Any()) .Returns(LdapGroupLookupResult.Of(LdapGroupLookupStatus.DirectoryUnavailable)); var state = await auth.GetAuthoritativeStateAsync(); Assert.Contains(state.User.FindAll(JwtTokenService.RoleClaimType), c => c.Value == Roles.Administrator); } /// DisableLogin (auto-login) never queries the directory. /// A task that represents the asynchronous operation. [Fact] public async Task DisableLogin_NeverQueriesTheDirectory() { var (auth, lookup, provider) = DirectoryCircuit([], disableLogin: true); using var _ = provider; await auth.GetAuthoritativeStateAsync(); await lookup.DidNotReceive().GetUserGroupsAsync(Arg.Any(), Arg.Any()); } }