9f67147e7b
Deferred-work register rows 37, 86, 87, 88 — the structural duplication and the three behavioral divergences it had accumulated, fixed together. Row 37 (structural). `IDeploymentManagerRepository.QueryDeploymentSummariesAsync` is DELETED. It and `QueryDeploymentListPageAsync` were two paged reads of the same insert-only table with different contracts — raw offset vs page number, no total vs total, no instance join vs joined, instance-id-list scoping vs site-id scoping. The CLI/HTTP `QueryDeployments` handler now goes through `QueryDeploymentListPageAsync` with a `DeploymentListFilter`, exactly like the Central UI page. The `DeploymentRecordSummary` projection went with it (`DeploymentListRow` is the single row shape); its `MaxPageSize` const moved to `DeploymentListPage`, which also gains `DefaultPageSize`. Row 86 (Medium, authorization — the headline). `SiteScopeService.ResolveAsync` was claims-only, so an Administrator carrying `SiteId` claims saw the whole fleet through the CLI (`ManagementActor.EnforceSiteScope` has always exempted Administrator) and only their granted sites on the Deployments page. One user, one dataset, two answers. Administrator = system-wide is the intended semantic on both sides; the UI was the defect. The role claim is read explicitly with an OrdinalIgnoreCase comparison rather than via `IsInRole`, which compares claim VALUES ordinally and only resolves when the identity carries the matching roleType — the two sides must not disagree on a differently-cased role name. An Administrator's site claims are not collected at all, so the invariant "system-wide => empty permitted-id set" holds for every caller. Designer is NOT exempt: the class doc claimed "any Admin/Design user" is system-wide, which was true of neither side, so the doc was corrected rather than the bypass widened. Row 87 (Low). The scoped branch used to resolve the site grant into an instance-id list by loading EVERY instance in the system, non-transactionally, on every scoped query; the grant now rides as `SiteIdScope` and is resolved by the query's own join. An unparseable permitted-site id is DROPPED, never ignored — dropping can only narrow a grant, discarding it would widen it to fleet-wide. And `PageSize <= 0` no longer means UNLIMITED down at the repository (`--page-size 0` materialized the whole retention window): it means "unspecified" and takes the CLI's own default of 50, capped at 1000. Bounding without telling would be silent truncation, so the handler now returns a `DeploymentQueryResult` envelope carrying `totalCount` plus the page coordinates actually applied — with a CLI table projection (mirroring row 57's `AuditConfigTableProjection`) that unwraps it and appends a "showing N of M" note. Row 88 (Low, UX). An unrecognised `--status` fell back to "no filter", so a typo like `--status Sucess` returned every status — a complete-looking answer to a question the caller never asked. It is now a `ManagementError` naming the valid values. Parsing stays case-insensitive; a numeric string is rejected too, since `Enum.TryParse` accepts "7" and yields an undefined value (guarded with `Enum.IsDefined`). Loud failure over silent full results, per row 46. Tests: 7 new `SiteScopeServiceTests` pinning Administrator = system-wide (verified failing 6/7 against the pre-fix service, the Designer pin passing both ways); the ManagementActor deployment suite migrated onto the consolidated query with new pins for the status error, case-insensitive parse, numeric rejection, page-size default/ cap/floor, empty-scope-on-garbage-grant, and "reads no instances at all"; a repository test for site-scope+status composing over a bounded page; and 6 CLI tests for the new table projection.
165 lines
6.0 KiB
C#
165 lines
6.0 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));
|
|
}
|
|
}
|