af9f14a668
SiteScopeService's instance methods resolve the AUTHORITATIVE state — a fail-closed, DB-backed group-to-role re-mapping. The indicator called them on every 30s tick, and it lives in MainLayout, so that was one or two forced session revalidations per open circuit per page, forever: exactly the per-circuit cost IPendingUpdatesCache exists to avoid. Extracts the pure rule as SiteScopeService.Resolve(ClaimsPrincipal) (no I/O; ResolveAsync now delegates to it, no behaviour change for existing callers). The indicator takes the cascaded AuthenticationState — falling back to a NON-forced GetAuthenticationStateAsync, as Templates/FlowDebugger do — and computes roles plus scope once per auth state, re-running on AuthenticationStateChanged so a mid-session revocation hides the block instead of leaving it stale for the life of the circuit. Also routes the first load through PollGate, adds aria-hidden to the icons, and comments the off-renderer field writes and the InvalidOperationException catch. Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
228 lines
8.3 KiB
C#
228 lines
8.3 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Components.Authorization;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
|
using ZB.MOM.WW.ScadaBridge.Security;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
|
|
|
|
/// <summary>
|
|
/// Regression tests for CentralUI-002. Site-scoped Deployment permissions are
|
|
/// written as <c>SiteId</c> claims at login but were never read — Deployment
|
|
/// pages listed and acted on every site. <see cref="SiteScopeService"/> is the
|
|
/// shared helper that reads those claims; these tests pin its behaviour.
|
|
/// </summary>
|
|
public class SiteScopeServiceTests
|
|
{
|
|
private sealed class StubAuthStateProvider : AuthenticationStateProvider
|
|
{
|
|
private readonly ClaimsPrincipal _user;
|
|
public StubAuthStateProvider(ClaimsPrincipal user) => _user = user;
|
|
public override Task<AuthenticationState> GetAuthenticationStateAsync()
|
|
=> Task.FromResult(new AuthenticationState(_user));
|
|
}
|
|
|
|
private static SiteScopeService ForUser(params Claim[] claims)
|
|
{
|
|
var identity = new ClaimsIdentity(claims, authenticationType: "TestCookie");
|
|
return new SiteScopeService(new StubAuthStateProvider(new ClaimsPrincipal(identity)));
|
|
}
|
|
|
|
private static Claim Role(string role) => new(JwtTokenService.RoleClaimType, role);
|
|
private static Claim SiteClaim(int id) => new(JwtTokenService.SiteIdClaimType, id.ToString());
|
|
|
|
private static List<Site> Sites(params int[] ids)
|
|
=> ids.Select(id => new Site($"Site{id}", $"SITE-{id}") { Id = id }).ToList();
|
|
|
|
[Fact]
|
|
public async Task DeploymentUserWithNoSiteClaims_IsSystemWide()
|
|
{
|
|
var svc = ForUser(Role("Deployer"));
|
|
|
|
Assert.True(await svc.IsSystemWideAsync());
|
|
Assert.Empty(await svc.PermittedSiteIdsAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SystemWideUser_FilterSites_ReturnsAllSites()
|
|
{
|
|
var svc = ForUser(Role("Deployer"));
|
|
|
|
var filtered = await svc.FilterSitesAsync(Sites(1, 2, 3));
|
|
|
|
Assert.Equal(new[] { 1, 2, 3 }, filtered.Select(s => s.Id));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ScopedUser_FilterSites_ReturnsOnlyPermittedSites()
|
|
{
|
|
// Regression: a Deployment user scoped to sites 1 and 3 must NOT see site 2.
|
|
var svc = ForUser(Role("Deployer"), SiteClaim(1), SiteClaim(3));
|
|
|
|
var filtered = await svc.FilterSitesAsync(Sites(1, 2, 3, 4));
|
|
|
|
Assert.Equal(new[] { 1, 3 }, filtered.Select(s => s.Id).OrderBy(x => x));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ScopedUser_IsSiteAllowed_OnlyForGrantedSites()
|
|
{
|
|
var svc = ForUser(Role("Deployer"), SiteClaim(5));
|
|
|
|
Assert.True(await svc.IsSiteAllowedAsync(5));
|
|
Assert.False(await svc.IsSiteAllowedAsync(6));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ScopedUser_IsNotSystemWide_AndReportsItsPermittedIds()
|
|
{
|
|
var svc = ForUser(Role("Deployer"), SiteClaim(7), SiteClaim(9));
|
|
|
|
Assert.False(await svc.IsSystemWideAsync());
|
|
Assert.Equal(new[] { 7, 9 }, (await svc.PermittedSiteIdsAsync()).OrderBy(x => x));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SystemWideUser_IsSiteAllowed_ForAnySite()
|
|
{
|
|
var svc = ForUser(Role("Deployer"));
|
|
|
|
Assert.True(await svc.IsSiteAllowedAsync(1));
|
|
Assert.True(await svc.IsSiteAllowedAsync(999));
|
|
}
|
|
|
|
// ── Administrator is system-wide regardless of SiteId claims (register 86) ──
|
|
//
|
|
// ManagementActor has always exempted Administrator from site scoping
|
|
// (EnforceSiteScope and every handler that calls it). This service was
|
|
// claims-only, so an Administrator who ALSO carried a scoped Deployer grant
|
|
// could act fleet-wide through the CLI/HTTP surface while the UI hid the same
|
|
// sites. The UI was the side that disagreed with the documented rule.
|
|
|
|
[Fact]
|
|
public async Task AdministratorWithSiteClaims_IsSystemWide()
|
|
{
|
|
var svc = ForUser(Role("Administrator"), Role("Deployer"), SiteClaim(1), SiteClaim(3));
|
|
|
|
Assert.True(await svc.IsSystemWideAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AdministratorWithSiteClaims_FilterSites_ReturnsAllSites()
|
|
{
|
|
var svc = ForUser(Role("Administrator"), Role("Deployer"), SiteClaim(1));
|
|
|
|
var filtered = await svc.FilterSitesAsync(Sites(1, 2, 3));
|
|
|
|
Assert.Equal(new[] { 1, 2, 3 }, filtered.Select(s => s.Id));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AdministratorWithSiteClaims_IsSiteAllowed_ForSitesOutsideTheClaims()
|
|
{
|
|
var svc = ForUser(Role("Administrator"), SiteClaim(1));
|
|
|
|
Assert.True(await svc.IsSiteAllowedAsync(2));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AdministratorWithSiteClaims_ReportsNoPermittedIds()
|
|
{
|
|
// The invariant "system-wide => empty id set" must hold for every caller, so
|
|
// a consumer that reads the ids without consulting IsSystemWide cannot
|
|
// silently narrow an Administrator to a subset of sites.
|
|
var svc = ForUser(Role("Administrator"), SiteClaim(1), SiteClaim(3));
|
|
|
|
Assert.Empty(await svc.PermittedSiteIdsAsync());
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("administrator")]
|
|
[InlineData("ADMINISTRATOR")]
|
|
public async Task AdministratorRoleMatch_IsCaseInsensitive(string role)
|
|
{
|
|
// ManagementActor compares roles with OrdinalIgnoreCase. ClaimsPrincipal
|
|
// .IsInRole compares claim VALUES ordinally, so matching the actor requires
|
|
// reading the role claims explicitly — otherwise the two sides would
|
|
// disagree on a differently-cased role name.
|
|
var svc = ForUser(Role(role), SiteClaim(1));
|
|
|
|
Assert.True(await svc.IsSystemWideAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DesignerWithSiteClaims_IsStillScoped()
|
|
{
|
|
// The class doc used to claim "any Admin/Design user" is system-wide. Only
|
|
// Administrator is — on either side of the rule — and the doc was corrected
|
|
// rather than the bypass widened.
|
|
var svc = ForUser(Role("Designer"), SiteClaim(1));
|
|
|
|
Assert.False(await svc.IsSystemWideAsync());
|
|
Assert.False(await svc.IsSiteAllowedAsync(2));
|
|
}
|
|
|
|
// ── Resolve(ClaimsPrincipal): the same rule as a pure function ───────────
|
|
// Display-gating callers (the rail pending-updates indicator) hold a principal
|
|
// already and must NOT pay for the instance methods' authoritative, fail-closed
|
|
// state refresh — which for a polling component would be a forced session
|
|
// revalidation per tick per circuit. These pin that the pure overload answers
|
|
// identically to the async path.
|
|
|
|
private static ClaimsPrincipal Principal(params Claim[] claims)
|
|
=> new(new ClaimsIdentity(claims, authenticationType: "TestCookie"));
|
|
|
|
[Fact]
|
|
public void Resolve_Administrator_IsSystemWideWithAnEmptySet()
|
|
{
|
|
// Even carrying SiteId claims: an Administrator's ids are not collected at
|
|
// all, so "system-wide => empty set" holds for every caller.
|
|
var (isSystemWide, sites) = SiteScopeService.Resolve(
|
|
Principal(Role("Administrator"), SiteClaim(1), SiteClaim(2)));
|
|
|
|
Assert.True(isSystemWide);
|
|
Assert.Empty(sites);
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_SiteClaims_YieldTheScopedSet()
|
|
{
|
|
var (isSystemWide, sites) = SiteScopeService.Resolve(
|
|
Principal(Role("Deployer"), SiteClaim(1), SiteClaim(3)));
|
|
|
|
Assert.False(isSystemWide);
|
|
Assert.Equal([1, 3], sites.OrderBy(id => id));
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_NoSiteClaims_IsSystemWide()
|
|
{
|
|
var (isSystemWide, sites) = SiteScopeService.Resolve(Principal(Role("Deployer")));
|
|
|
|
Assert.True(isSystemWide);
|
|
Assert.Empty(sites);
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_UnauthenticatedPrincipal_IsScopedToNothing()
|
|
{
|
|
// Fail closed: not system-wide AND no permitted sites.
|
|
var (isSystemWide, sites) = SiteScopeService.Resolve(new ClaimsPrincipal(new ClaimsIdentity()));
|
|
|
|
Assert.False(isSystemWide);
|
|
Assert.Empty(sites);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Resolve_AgreesWithTheAsyncPath()
|
|
{
|
|
var claims = new[] { Role("Deployer"), SiteClaim(2), SiteClaim(5) };
|
|
var svc = ForUser(claims);
|
|
|
|
var (isSystemWide, sites) = SiteScopeService.Resolve(Principal(claims));
|
|
|
|
Assert.Equal(await svc.IsSystemWideAsync(), isSystemWide);
|
|
Assert.Equal((await svc.PermittedSiteIdsAsync()).OrderBy(id => id), sites.OrderBy(id => id));
|
|
}
|
|
}
|