Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/SiteScopeServiceTests.cs
T
Joseph Doherty 513fa1aa16 docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
2026-10-07 15:05:49 -04:00

266 lines
12 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 site-scoped permissions. 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;
/// <summary>Initializes a new instance of the <c>StubAuthStateProvider</c> class.</summary>
/// <param name="user">The user.</param>
public StubAuthStateProvider(ClaimsPrincipal user) => _user = user;
/// <inheritdoc />
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();
/// <summary>Verifies that a deployer with no site claims is treated as system-wide.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeploymentUserWithNoSiteClaims_IsSystemWide()
{
var svc = ForUser(Role("Deployer"));
Assert.True(await svc.IsSystemWideAsync());
Assert.Empty(await svc.PermittedSiteIdsAsync());
}
/// <summary>Verifies that filtering sites for a system-wide user returns every site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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));
}
/// <summary>Verifies that filtering sites for a scoped user returns only the permitted sites.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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));
}
/// <summary>Verifies that a scoped user is allowed access only to the sites they were granted.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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));
}
/// <summary>Verifies that a scoped user is not system-wide and reports its permitted site ids.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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));
}
/// <summary>Verifies that a system-wide user is allowed access to any site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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.
/// <summary>Verifies that an administrator carrying site claims is still system-wide.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AdministratorWithSiteClaims_IsSystemWide()
{
var svc = ForUser(Role("Administrator"), Role("Deployer"), SiteClaim(1), SiteClaim(3));
Assert.True(await svc.IsSystemWideAsync());
}
/// <summary>Verifies that filtering sites for an administrator with site claims returns every site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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));
}
/// <summary>Verifies that an administrator with site claims is allowed access to sites outside those claims.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AdministratorWithSiteClaims_IsSiteAllowed_ForSitesOutsideTheClaims()
{
var svc = ForUser(Role("Administrator"), SiteClaim(1));
Assert.True(await svc.IsSiteAllowedAsync(2));
}
/// <summary>Verifies that an administrator with site claims reports no permitted site ids.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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());
}
/// <summary>Verifies that the administrator role match ignores case.</summary>
/// <param name="role">The role.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[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());
}
/// <summary>Verifies that a designer with site claims remains scoped to those sites.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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"));
/// <summary>Verifies that resolving an administrator yields a system-wide scope with an empty site set.</summary>
/// <returns>The result.</returns>
[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);
}
/// <summary>Verifies that resolving a principal with site claims yields the scoped site set.</summary>
/// <returns>The result.</returns>
[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));
}
/// <summary>Verifies that resolving a principal with no site claims yields a system-wide scope.</summary>
/// <returns>The result.</returns>
[Fact]
public void Resolve_NoSiteClaims_IsSystemWide()
{
var (isSystemWide, sites) = SiteScopeService.Resolve(Principal(Role("Deployer")));
Assert.True(isSystemWide);
Assert.Empty(sites);
}
/// <summary>Verifies that resolving an unauthenticated principal yields a scope with no permitted sites.</summary>
/// <returns>The result.</returns>
[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);
}
/// <summary>Verifies that the synchronous resolve produces the same result as the async path.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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));
}
}