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