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; /// /// Regression tests for site-scoped permissions. Site-scoped Deployment permissions are /// written as SiteId claims at login but were never read — Deployment /// pages listed and acted on every site. is the /// shared helper that reads those claims; these tests pin its behaviour. /// public class SiteScopeServiceTests { private sealed class StubAuthStateProvider : AuthenticationStateProvider { private readonly ClaimsPrincipal _user; /// Initializes a new instance of the StubAuthStateProvider class. /// The user. public StubAuthStateProvider(ClaimsPrincipal user) => _user = user; /// public override Task 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 Sites(params int[] ids) => ids.Select(id => new Site($"Site{id}", $"SITE-{id}") { Id = id }).ToList(); /// Verifies that a deployer with no site claims is treated as system-wide. /// A task that represents the asynchronous operation. [Fact] public async Task DeploymentUserWithNoSiteClaims_IsSystemWide() { var svc = ForUser(Role("Deployer")); Assert.True(await svc.IsSystemWideAsync()); Assert.Empty(await svc.PermittedSiteIdsAsync()); } /// Verifies that filtering sites for a system-wide user returns every site. /// A task that represents the asynchronous operation. [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)); } /// Verifies that filtering sites for a scoped user returns only the permitted sites. /// A task that represents the asynchronous operation. [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)); } /// Verifies that a scoped user is allowed access only to the sites they were granted. /// A task that represents the asynchronous operation. [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)); } /// Verifies that a scoped user is not system-wide and reports its permitted site ids. /// A task that represents the asynchronous operation. [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)); } /// Verifies that a system-wide user is allowed access to any site. /// A task that represents the asynchronous operation. [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. /// Verifies that an administrator carrying site claims is still system-wide. /// A task that represents the asynchronous operation. [Fact] public async Task AdministratorWithSiteClaims_IsSystemWide() { var svc = ForUser(Role("Administrator"), Role("Deployer"), SiteClaim(1), SiteClaim(3)); Assert.True(await svc.IsSystemWideAsync()); } /// Verifies that filtering sites for an administrator with site claims returns every site. /// A task that represents the asynchronous operation. [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)); } /// Verifies that an administrator with site claims is allowed access to sites outside those claims. /// A task that represents the asynchronous operation. [Fact] public async Task AdministratorWithSiteClaims_IsSiteAllowed_ForSitesOutsideTheClaims() { var svc = ForUser(Role("Administrator"), SiteClaim(1)); Assert.True(await svc.IsSiteAllowedAsync(2)); } /// Verifies that an administrator with site claims reports no permitted site ids. /// A task that represents the asynchronous operation. [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()); } /// Verifies that the administrator role match ignores case. /// The role. /// A task that represents the asynchronous operation. [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()); } /// Verifies that a designer with site claims remains scoped to those sites. /// A task that represents the asynchronous operation. [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")); /// Verifies that resolving an administrator yields a system-wide scope with an empty site set. /// The result. [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); } /// Verifies that resolving a principal with site claims yields the scoped site set. /// The result. [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)); } /// Verifies that resolving a principal with no site claims yields a system-wide scope. /// The result. [Fact] public void Resolve_NoSiteClaims_IsSystemWide() { var (isSystemWide, sites) = SiteScopeService.Resolve(Principal(Role("Deployer"))); Assert.True(isSystemWide); Assert.Empty(sites); } /// Verifies that resolving an unauthenticated principal yields a scope with no permitted sites. /// The result. [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); } /// Verifies that the synchronous resolve produces the same result as the async path. /// A task that represents the asynchronous operation. [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)); } }