refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
using Microsoft.AspNetCore.Antiforgery;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for CentralUI-017. <c>POST /auth/logout</c> called
|
||||
/// <c>.DisableAntiforgery()</c> and a plain <c>GET /logout</c> route also
|
||||
/// signed the user out — either could be triggered cross-site to forcibly log
|
||||
/// a user out. Logout is a state-changing authenticated action and must be
|
||||
/// CSRF-protected: the POST keeps antiforgery enabled and the state-changing
|
||||
/// GET route is removed.
|
||||
/// </summary>
|
||||
public class AuthEndpointsCsrfTests
|
||||
{
|
||||
private static IReadOnlyList<RouteEndpoint> BuildEndpoints()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.Services.AddRouting();
|
||||
builder.Services.AddAntiforgery();
|
||||
// Dispose the host: an undisposed WebApplication leaks its config
|
||||
// PhysicalFileProvider (appsettings reload-watch FileSystemWatcher — a
|
||||
// process-wide macOS run-loop thread) and a ConsoleLoggerProcessor
|
||||
// thread, which keep the test host process alive after the run.
|
||||
using var app = builder.Build();
|
||||
app.MapAuthEndpoints();
|
||||
|
||||
return ((IEndpointRouteBuilder)app).DataSources
|
||||
.SelectMany(ds => ds.Endpoints)
|
||||
.OfType<RouteEndpoint>()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static RouteEndpoint? Find(IReadOnlyList<RouteEndpoint> endpoints, string pattern, string method)
|
||||
=> endpoints.FirstOrDefault(e =>
|
||||
e.RoutePattern.RawText == pattern &&
|
||||
(e.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods.Contains(method) ?? false));
|
||||
|
||||
[Fact]
|
||||
public void PostAuthLogout_DoesNotDisableAntiforgery()
|
||||
{
|
||||
var endpoints = BuildEndpoints();
|
||||
var logout = Find(endpoints, "/auth/logout", "POST");
|
||||
|
||||
Assert.NotNull(logout);
|
||||
// DisableAntiforgery() leaves an IAntiforgeryMetadata with
|
||||
// RequiresValidation == false. A CSRF-protected POST has either no such
|
||||
// metadata, or metadata that still requires validation.
|
||||
var antiforgery = logout!.Metadata.GetMetadata<IAntiforgeryMetadata>();
|
||||
Assert.True(antiforgery is null || antiforgery.RequiresValidation,
|
||||
"POST /auth/logout must keep antiforgery validation enabled.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLogout_StateChangingRoute_IsRemoved()
|
||||
{
|
||||
var endpoints = BuildEndpoints();
|
||||
var getLogout = Find(endpoints, "/logout", "GET");
|
||||
|
||||
Assert.Null(getLogout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PostAuthLogin_StillDisablesAntiforgery_PreAuthIsAcceptable()
|
||||
{
|
||||
// Login is a pre-auth endpoint; disabling antiforgery there is acceptable
|
||||
// and intentional. This pins that the fix did not over-correct.
|
||||
var endpoints = BuildEndpoints();
|
||||
var login = Find(endpoints, "/auth/login", "POST");
|
||||
|
||||
Assert.NotNull(login);
|
||||
var antiforgery = login!.Metadata.GetMetadata<IAntiforgeryMetadata>();
|
||||
Assert.NotNull(antiforgery);
|
||||
Assert.False(antiforgery!.RequiresValidation);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for CentralUI-020. The Blazor circuit's
|
||||
/// <c>CookieAuthenticationStateProvider</c> serves a frozen constructor-time
|
||||
/// principal, so <c>SessionExpiry</c> could never observe a server-side cookie
|
||||
/// expiry by polling the auth state. The fix adds <c>GET /auth/ping</c>, an
|
||||
/// endpoint evaluated per HTTP request (where the cookie middleware re-validates
|
||||
/// the cookie): it returns 200 while the session is live and 401 once the
|
||||
/// cookie has lapsed, giving <c>SessionExpiry</c> a real signal to redirect on.
|
||||
/// </summary>
|
||||
public class AuthPingEndpointTests
|
||||
{
|
||||
private static IReadOnlyList<RouteEndpoint> BuildEndpoints()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.Services.AddRouting();
|
||||
builder.Services.AddAntiforgery();
|
||||
// Dispose the host: an undisposed WebApplication leaks its config
|
||||
// PhysicalFileProvider (appsettings reload-watch FileSystemWatcher — a
|
||||
// process-wide macOS run-loop thread) and a ConsoleLoggerProcessor
|
||||
// thread, which keep the test host process alive after the run.
|
||||
using var app = builder.Build();
|
||||
app.MapAuthEndpoints();
|
||||
|
||||
return ((IEndpointRouteBuilder)app).DataSources
|
||||
.SelectMany(ds => ds.Endpoints)
|
||||
.OfType<RouteEndpoint>()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static RouteEndpoint? Find(IReadOnlyList<RouteEndpoint> endpoints, string pattern, string method)
|
||||
=> endpoints.FirstOrDefault(e =>
|
||||
e.RoutePattern.RawText == pattern &&
|
||||
(e.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods.Contains(method) ?? false));
|
||||
|
||||
[Fact]
|
||||
public void AuthPing_GetRoute_IsMapped()
|
||||
{
|
||||
var ping = Find(BuildEndpoints(), "/auth/ping", "GET");
|
||||
Assert.NotNull(ping);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthPing_AnonymousUser_Returns401()
|
||||
{
|
||||
var context = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity()) // not authenticated
|
||||
};
|
||||
|
||||
await AuthEndpoints.HandlePing(context);
|
||||
|
||||
Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthPing_AuthenticatedUser_Returns200()
|
||||
{
|
||||
var identity = new ClaimsIdentity(
|
||||
new[] { new Claim(ClaimTypes.Name, "alice") }, authenticationType: "TestCookie");
|
||||
var context = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(identity)
|
||||
};
|
||||
|
||||
await AuthEndpoints.HandlePing(context);
|
||||
|
||||
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthPing_DoesNotTriggerCookieRedirect()
|
||||
{
|
||||
// The endpoint must NOT use RequireAuthorization(): that would make the
|
||||
// cookie middleware answer an expired request with a 302 to /login,
|
||||
// which a fetch() follows transparently and reads as a 200 login page —
|
||||
// SessionExpiry would never see the expiry. The endpoint allows
|
||||
// anonymous access and decides 200/401 itself.
|
||||
var ping = Find(BuildEndpoints(), "/auth/ping", "GET");
|
||||
Assert.NotNull(ping);
|
||||
|
||||
var authorize = ping!.Metadata
|
||||
.GetOrderedMetadata<Microsoft.AspNetCore.Authorization.IAuthorizeData>();
|
||||
Assert.Empty(authorize);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for CentralUI-024. Ten components each copy-pasted a
|
||||
/// <c>GetCurrentUserAsync</c> helper using the magic string
|
||||
/// <c>FindFirst("Username")</c>, and <c>NavMenu</c>/<c>Dashboard</c> used
|
||||
/// <c>FindFirst("DisplayName")</c>. A rename of the claim type in
|
||||
/// <see cref="JwtTokenService"/> (the single source of truth) would have
|
||||
/// silently broken every call site. The shared
|
||||
/// <see cref="ClaimsPrincipalExtensions"/> helpers now resolve the claim type
|
||||
/// through the <c>JwtTokenService</c> constants.
|
||||
/// </summary>
|
||||
public class ClaimsPrincipalExtensionsTests
|
||||
{
|
||||
private static ClaimsPrincipal Principal(params Claim[] claims)
|
||||
=> new(new ClaimsIdentity(claims, authenticationType: "TestCookie"));
|
||||
|
||||
[Fact]
|
||||
public void GetUsername_ResolvesTheJwtTokenServiceUsernameClaim()
|
||||
{
|
||||
var principal = Principal(
|
||||
new Claim(JwtTokenService.UsernameClaimType, "alice"));
|
||||
|
||||
Assert.Equal("alice", principal.GetUsername());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetUsername_FallsBackToUnknown_WhenClaimAbsent()
|
||||
{
|
||||
var principal = Principal();
|
||||
|
||||
Assert.Equal(ClaimsPrincipalExtensions.UnknownUser, principal.GetUsername());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDisplayName_ResolvesTheJwtTokenServiceDisplayNameClaim()
|
||||
{
|
||||
var principal = Principal(
|
||||
new Claim(JwtTokenService.DisplayNameClaimType, "Alice Anderson"));
|
||||
|
||||
Assert.Equal("Alice Anderson", principal.GetDisplayName());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDisplayName_IsNull_WhenClaimAbsent()
|
||||
{
|
||||
Assert.Null(Principal().GetDisplayName());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCurrentUsernameAsync_ReadsUsernameFromAuthState()
|
||||
{
|
||||
var principal = Principal(
|
||||
new Claim(JwtTokenService.UsernameClaimType, "bob"));
|
||||
var provider = new StubAuthStateProvider(
|
||||
new AuthenticationState(principal));
|
||||
|
||||
Assert.Equal("bob", await provider.GetCurrentUsernameAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Username_LookupTracksAJwtTokenServiceRename()
|
||||
{
|
||||
// The lookup must NOT use a hard-coded "Username" literal: if the
|
||||
// constant's *value* is ever changed, the helper must follow it. Build a
|
||||
// principal whose claim carries the JwtTokenService constant's current
|
||||
// value and confirm the helper finds it via that same constant.
|
||||
var principal = Principal(
|
||||
new Claim(JwtTokenService.UsernameClaimType, "carol"));
|
||||
|
||||
Assert.Equal("carol",
|
||||
principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value);
|
||||
Assert.Equal("carol", principal.GetUsername());
|
||||
}
|
||||
|
||||
private sealed class StubAuthStateProvider : AuthenticationStateProvider
|
||||
{
|
||||
private readonly AuthenticationState _state;
|
||||
public StubAuthStateProvider(AuthenticationState state) => _state = state;
|
||||
public override Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||
=> Task.FromResult(_state);
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for CentralUI-004. The provider used to read
|
||||
/// <see cref="IHttpContextAccessor.HttpContext"/> on every call; once the Blazor
|
||||
/// circuit is established that context is gone, so later re-evaluations saw an
|
||||
/// unauthenticated principal. The provider must snapshot the principal once at
|
||||
/// construction (during the initial HTTP request) and serve it for the circuit.
|
||||
/// </summary>
|
||||
public class CookieAuthenticationStateProviderTests
|
||||
{
|
||||
private static ClaimsPrincipal AuthenticatedUser(string name)
|
||||
{
|
||||
var identity = new ClaimsIdentity(
|
||||
new[] { new Claim(ClaimTypes.Name, name) },
|
||||
authenticationType: "TestCookie");
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAuthenticationStateAsync_ReturnsAuthenticatedUser_WhenHttpContextPresent()
|
||||
{
|
||||
var accessor = new HttpContextAccessor
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = AuthenticatedUser("alice") }
|
||||
};
|
||||
|
||||
var provider = new CookieAuthenticationStateProvider(accessor);
|
||||
var state = await provider.GetAuthenticationStateAsync();
|
||||
|
||||
Assert.True(state.User.Identity?.IsAuthenticated);
|
||||
Assert.Equal("alice", state.User.Identity?.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAuthenticationStateAsync_StillReturnsUser_AfterHttpContextIsGone()
|
||||
{
|
||||
// The circuit is built during the HTTP request: HttpContext is valid then.
|
||||
var accessor = new HttpContextAccessor
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = AuthenticatedUser("bob") }
|
||||
};
|
||||
var provider = new CookieAuthenticationStateProvider(accessor);
|
||||
|
||||
// After the request completes, IHttpContextAccessor.HttpContext is null for
|
||||
// the life of the long-lived SignalR circuit.
|
||||
accessor.HttpContext = null;
|
||||
|
||||
var state = await provider.GetAuthenticationStateAsync();
|
||||
|
||||
// The pre-fix implementation returned an anonymous principal here.
|
||||
Assert.True(state.User.Identity?.IsAuthenticated);
|
||||
Assert.Equal("bob", state.User.Identity?.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAuthenticationStateAsync_IsStableAcrossCalls_IgnoringStaleForeignContext()
|
||||
{
|
||||
var accessor = new HttpContextAccessor
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = AuthenticatedUser("carol") }
|
||||
};
|
||||
var provider = new CookieAuthenticationStateProvider(accessor);
|
||||
|
||||
// A stale/foreign context leaking through the AsyncLocal accessor must NOT
|
||||
// change what this circuit's provider reports.
|
||||
accessor.HttpContext = new DefaultHttpContext { User = AuthenticatedUser("intruder") };
|
||||
|
||||
var first = await provider.GetAuthenticationStateAsync();
|
||||
var second = await provider.GetAuthenticationStateAsync();
|
||||
|
||||
Assert.Equal("carol", first.User.Identity?.Name);
|
||||
Assert.Equal("carol", second.User.Identity?.Name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Bunit;
|
||||
using Bunit.TestDoubles;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for CentralUI-020 and CentralUI-025. <c>SessionExpiry</c>
|
||||
/// used to poll the Blazor <c>AuthenticationStateProvider</c>, which (via
|
||||
/// <c>CookieAuthenticationStateProvider</c>) serves a frozen constructor-time
|
||||
/// principal — so the polled state could never become "expired" and the
|
||||
/// idle-logout redirect never fired. The component now polls the server
|
||||
/// <c>GET /auth/ping</c> endpoint, which reflects the live cookie session: a
|
||||
/// 401 response triggers a redirect to <c>/login</c>. These tests exercise that
|
||||
/// redirect path directly (CentralUI-025: the path was previously untested).
|
||||
/// </summary>
|
||||
public class SessionExpiryComponentTests : BunitContext
|
||||
{
|
||||
private const string ModulePath = "./_content/ZB.MOM.WW.ScadaBridge.CentralUI/js/session-expiry.js";
|
||||
|
||||
[Fact]
|
||||
public async Task CheckSession_ExpiredSession_RedirectsToLogin()
|
||||
{
|
||||
// The server reports the cookie has lapsed: ping returns HTTP 401.
|
||||
var module = JSInterop.SetupModule(ModulePath);
|
||||
module.Setup<int>("ping", "/auth/ping").SetResult(401);
|
||||
|
||||
var nav = Services.GetRequiredService<NavigationManager>();
|
||||
var cut = Render<SessionExpiry>();
|
||||
|
||||
await cut.InvokeAsync(() => cut.Instance.CheckSessionAsync());
|
||||
|
||||
Assert.EndsWith("/login", nav.Uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckSession_LiveSession_DoesNotRedirect()
|
||||
{
|
||||
// The server reports the session is still valid: ping returns HTTP 200.
|
||||
var module = JSInterop.SetupModule(ModulePath);
|
||||
module.Setup<int>("ping", "/auth/ping").SetResult(200);
|
||||
|
||||
var nav = Services.GetRequiredService<NavigationManager>();
|
||||
var before = nav.Uri;
|
||||
var cut = Render<SessionExpiry>();
|
||||
|
||||
await cut.InvokeAsync(() => cut.Instance.CheckSessionAsync());
|
||||
|
||||
Assert.Equal(before, nav.Uri);
|
||||
Assert.DoesNotContain("/login", nav.Uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckSession_TransientNetworkFailure_DoesNotRedirect()
|
||||
{
|
||||
// A network blip surfaces as status 0 — inconclusive. The component must
|
||||
// NOT log an authenticated user out on a transient failure.
|
||||
var module = JSInterop.SetupModule(ModulePath);
|
||||
module.Setup<int>("ping", "/auth/ping").SetResult(0);
|
||||
|
||||
var nav = Services.GetRequiredService<NavigationManager>();
|
||||
var before = nav.Uri;
|
||||
var cut = Render<SessionExpiry>();
|
||||
|
||||
await cut.InvokeAsync(() => cut.Instance.CheckSessionAsync());
|
||||
|
||||
Assert.Equal(before, nav.Uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckSession_OnLoginPage_DoesNotPingOrRedirect()
|
||||
{
|
||||
// On /login the component must neither poll nor redirect (a /login →
|
||||
// /login redirect would loop). JSInterop is left in Strict mode with no
|
||||
// module setup, so any ping call would throw and fail the test.
|
||||
var nav = (BunitNavigationManager)Services
|
||||
.GetRequiredService<NavigationManager>();
|
||||
nav.NavigateTo("login");
|
||||
|
||||
var cut = Render<SessionExpiry>();
|
||||
await cut.InvokeAsync(() => cut.Instance.CheckSessionAsync());
|
||||
|
||||
// No JS module import was attempted and the URL is unchanged.
|
||||
Assert.EndsWith("/login", nav.Uri);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for CentralUI-005. <c>AuthEndpoints</c> previously stamped a
|
||||
/// fixed <c>expires_at = UtcNow + 30 min</c> claim and a 30-minute absolute cookie
|
||||
/// <c>ExpiresUtc</c> with no sliding refresh, contradicting the documented
|
||||
/// "sliding refresh, 30-minute idle timeout" policy. The login handler must now
|
||||
/// build <see cref="AuthenticationProperties"/> that let the cookie middleware
|
||||
/// own expiry (sliding window) rather than imposing a contradictory fixed
|
||||
/// absolute cap.
|
||||
/// </summary>
|
||||
public class SessionExpiryPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildSignInProperties_DoesNotSetFixedAbsoluteExpiry()
|
||||
{
|
||||
var props = AuthEndpoints.BuildSignInProperties();
|
||||
|
||||
// A fixed ExpiresUtc would re-introduce the hard 30-minute cap that
|
||||
// overrides the middleware's sliding window. Expiry must be owned by
|
||||
// the cookie middleware (ExpireTimeSpan + SlidingExpiration).
|
||||
Assert.Null(props.ExpiresUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSignInProperties_IsPersistent()
|
||||
{
|
||||
var props = AuthEndpoints.BuildSignInProperties();
|
||||
|
||||
Assert.True(props.IsPersistent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSignInProperties_AllowsSlidingRefresh()
|
||||
{
|
||||
var props = AuthEndpoints.BuildSignInProperties();
|
||||
|
||||
// AllowRefresh left null/true lets the cookie middleware slide the
|
||||
// expiry on activity. A false value would freeze the session to an
|
||||
// absolute cap — the bug this finding pins.
|
||||
Assert.NotEqual(false, props.AllowRefresh);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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("Deployment"));
|
||||
|
||||
Assert.True(await svc.IsSystemWideAsync());
|
||||
Assert.Empty(await svc.PermittedSiteIdsAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SystemWideUser_FilterSites_ReturnsAllSites()
|
||||
{
|
||||
var svc = ForUser(Role("Deployment"));
|
||||
|
||||
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("Deployment"), 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("Deployment"), 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("Deployment"), 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("Deployment"));
|
||||
|
||||
Assert.True(await svc.IsSiteAllowedAsync(1));
|
||||
Assert.True(await svc.IsSiteAllowedAsync(999));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user