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:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -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);
}
}
@@ -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));
}
}