fix(central-ui): pending-updates indicator resolves roles/scope once per auth state, not per poll

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
This commit is contained in:
Joseph Doherty
2026-09-14 14:15:49 -04:00
parent 6dc784bca4
commit af9f14a668
4 changed files with 386 additions and 37 deletions
@@ -161,4 +161,67 @@ public class SiteScopeServiceTests
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));
}
}