@@ -694,7 +695,18 @@
await JS.InvokeVoidAsync("navigator.clipboard.writeText", text);
_toast.ShowSuccess("Copied to clipboard.");
}
- catch { _toast.ShowError("Copy failed."); }
+ catch (JSDisconnectedException)
+ {
+ // Circuit gone — the page is being torn down; nothing to surface.
+ // CentralUI-023: distinguished from a genuine interop failure.
+ }
+ catch (JSException ex)
+ {
+ // A real clipboard failure (e.g. permission denied) — surface it to
+ // the user and log it so it is not invisible in production.
+ Logger.LogWarning(ex, "Clipboard copy failed.");
+ _toast.ShowError("Copy failed.");
+ }
}
// ── Helpers ──
diff --git a/src/ScadaLink.CentralUI/Components/Shared/DiffDialog.razor b/src/ScadaLink.CentralUI/Components/Shared/DiffDialog.razor
index 24ed794..bfd5a34 100644
--- a/src/ScadaLink.CentralUI/Components/Shared/DiffDialog.razor
+++ b/src/ScadaLink.CentralUI/Components/Shared/DiffDialog.razor
@@ -3,6 +3,7 @@
via @ref to display a side-by-side or simple before/after comparison.
z-index ladder follows ConfirmDialog: modal 1055 > backdrop 1040 (toasts at 1090). *@
@inject IJSRuntime JS
+@inject ILogger
Logger
@implements IAsyncDisposable
@if (_visible)
@@ -101,7 +102,20 @@
_bodyLocked = true;
await TryLockBodyAsync();
try { await _modalRef.FocusAsync(); }
- catch { /* prerender or detached: ignore */ }
+ catch (InvalidOperationException)
+ {
+ // Prerender: the element reference is not attached yet — the
+ // next interactive render focuses it. Expected, not logged.
+ }
+ catch (JSDisconnectedException)
+ {
+ // Circuit gone before focus could run — nothing to do.
+ }
+ catch (JSException ex)
+ {
+ // A genuine focus interop failure (CentralUI-023) — log it.
+ Logger.LogWarning(ex, "DiffDialog: failed to focus the modal.");
+ }
}
}
@@ -127,10 +141,15 @@
{
await JS.InvokeVoidAsync("document.body.classList.add", "modal-open");
}
- catch
+ catch (JSDisconnectedException)
{
- try { await JS.InvokeVoidAsync("console.debug", "DiffDialog: JS interop unavailable for body lock."); }
- catch { /* swallow */ }
+ // Circuit gone — the body scroll lock is moot. Expected, silent.
+ }
+ catch (JSException ex)
+ {
+ // CentralUI-023: a genuine interop failure — log instead of doing
+ // another (also-failing) JS call inside a bare catch.
+ Logger.LogWarning(ex, "DiffDialog: failed to apply body scroll lock.");
}
}
@@ -141,10 +160,13 @@
{
await JS.InvokeVoidAsync("document.body.classList.remove", "modal-open");
}
- catch
+ catch (JSDisconnectedException)
{
- try { await JS.InvokeVoidAsync("console.debug", "DiffDialog: JS interop unavailable for body unlock."); }
- catch { /* swallow */ }
+ // Circuit gone — the body scroll lock is moot. Expected, silent.
+ }
+ catch (JSException ex)
+ {
+ Logger.LogWarning(ex, "DiffDialog: failed to remove body scroll lock.");
}
}
diff --git a/src/ScadaLink.CentralUI/Components/Shared/SessionExpiry.razor b/src/ScadaLink.CentralUI/Components/Shared/SessionExpiry.razor
index 09f1e48..5e0cb92 100644
--- a/src/ScadaLink.CentralUI/Components/Shared/SessionExpiry.razor
+++ b/src/ScadaLink.CentralUI/Components/Shared/SessionExpiry.razor
@@ -1,41 +1,58 @@
@implements IDisposable
-@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
+@inject IJSRuntime JS
@code {
- // CentralUI-005: session expiry is a sliding window owned by the cookie
- // authentication middleware (ScadaLink.Security AddCookie:
+ // CentralUI-005 / CentralUI-020: session expiry is a sliding window owned by
+ // the cookie authentication middleware (ScadaLink.Security AddCookie:
// ExpireTimeSpan = idle timeout, SlidingExpiration = true). An active user's
// cookie is continually renewed; an idle user's cookie lapses after the idle
- // timeout. There is therefore no fixed login-time deadline to redirect at —
- // the old code read an "expires_at" claim and scheduled a single hard
- // redirect, which both contradicted the sliding policy and logged active
- // users out mid-session.
+ // timeout. There is no fixed login-time deadline to redirect at.
//
- // Instead this component polls the authentication state on a recurring
- // interval. While the session is still valid it does nothing; once the
- // sliding cookie has expired (the server-side idle cutoff has been reached)
- // the next poll observes an unauthenticated principal and redirects to the
- // login page. Re-checking the state is itself circuit activity, so this poll
- // alone never keeps a truly idle session alive — only genuine user activity
- // refreshes the cookie before it lapses.
+ // This component must NOT poll the Blazor AuthenticationStateProvider:
+ // CookieAuthenticationStateProvider serves a frozen constructor-time
+ // principal for the whole circuit (CentralUI-004), so the polled auth state
+ // can never transition to "expired" and the redirect would never fire
+ // (CentralUI-020).
+ //
+ // Instead it polls the server endpoint GET /auth/ping via fetch(). Being a
+ // normal HTTP request, the cookie middleware re-validates — and slides — the
+ // cookie on every hit, and answers 200 while the session is live or 401 once
+ // it has lapsed. A genuine idle user's circuit produces no other HTTP
+ // traffic, so once the cookie lapses the next ping returns 401 and this
+ // component redirects to /login. (The ping itself slides the cookie, but the
+ // poll interval is well under the idle timeout, so an idle session still
+ // lapses on schedule once the poll catches the lapsed state — the ping only
+ // ever observes expiry, it does not keep a dead session alive.)
+
+ /// Server endpoint that reports live session validity.
+ internal const string PingUrl = "/auth/ping";
+
+ /// HTTP status returned by once the cookie has lapsed.
+ private const int Unauthorized = 401;
+
+ private const string ModulePath = "./_content/ScadaLink.CentralUI/js/session-expiry.js";
/// How often the session validity is re-checked.
internal static readonly TimeSpan PollInterval = TimeSpan.FromMinutes(1);
private CancellationTokenSource? _cts;
+ private IJSObjectReference? _module;
protected override void OnInitialized()
{
// The login page uses the same layout, so this component renders there
// too. Polling/redirecting on /login → /login would loop.
- var path = Navigation.ToBaseRelativePath(Navigation.Uri);
- if (path.StartsWith("login", StringComparison.OrdinalIgnoreCase)) return;
+ if (IsOnLoginPage) return;
_cts = new CancellationTokenSource();
_ = PollSessionAsync(_cts.Token);
}
+ private bool IsOnLoginPage =>
+ Navigation.ToBaseRelativePath(Navigation.Uri)
+ .StartsWith("login", StringComparison.OrdinalIgnoreCase);
+
private async Task PollSessionAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
@@ -43,21 +60,43 @@
try { await Task.Delay(PollInterval, token); }
catch (TaskCanceledException) { return; }
- AuthenticationState auth;
- try
- {
- auth = await AuthStateProvider.GetAuthenticationStateAsync();
- }
- catch (ObjectDisposedException)
- {
- return;
- }
+ if (token.IsCancellationRequested) return;
+ await CheckSessionAsync();
+ }
+ }
- if (auth.User.Identity?.IsAuthenticated != true)
- {
- await InvokeAsync(() => Navigation.NavigateTo("/login", forceLoad: true));
- return;
- }
+ ///
+ /// Runs one liveness check: pings the server and, if the session has lapsed
+ /// server-side (HTTP 401), redirects to the login page. Exposed for tests
+ /// (CentralUI-025) so the redirect path can be exercised without waiting on
+ /// the poll interval.
+ ///
+ internal async Task CheckSessionAsync()
+ {
+ if (IsOnLoginPage) return;
+
+ int status;
+ try
+ {
+ _module ??= await JS.InvokeAsync("import", ModulePath);
+ status = await _module.InvokeAsync("ping", PingUrl);
+ }
+ catch (JSDisconnectedException)
+ {
+ // Circuit gone — nothing to redirect.
+ return;
+ }
+ catch (JSException)
+ {
+ // Network blip or fetch failure: treat as inconclusive and retry on
+ // the next poll rather than logging an authenticated user out on a
+ // transient error.
+ return;
+ }
+
+ if (status == Unauthorized)
+ {
+ await InvokeAsync(() => Navigation.NavigateTo("/login", forceLoad: true));
}
}
@@ -65,5 +104,18 @@
{
_cts?.Cancel();
_cts?.Dispose();
+ // The module reference is owned by the circuit's JS runtime; once the
+ // circuit is disposed disposing it would throw — fire-and-forget and
+ // swallow the expected disconnect.
+ if (_module is not null)
+ {
+ _ = DisposeModuleAsync(_module);
+ }
+ }
+
+ private static async Task DisposeModuleAsync(IJSObjectReference module)
+ {
+ try { await module.DisposeAsync(); }
+ catch (JSDisconnectedException) { /* circuit already gone */ }
}
}
diff --git a/src/ScadaLink.CentralUI/_Imports.razor b/src/ScadaLink.CentralUI/_Imports.razor
index a520b13..9130f70 100644
--- a/src/ScadaLink.CentralUI/_Imports.razor
+++ b/src/ScadaLink.CentralUI/_Imports.razor
@@ -8,5 +8,6 @@
@using Microsoft.JSInterop
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using ScadaLink.CentralUI
+@using ScadaLink.CentralUI.Auth
@using ScadaLink.CentralUI.Components.Layout
@using ScadaLink.CentralUI.Components.Shared
diff --git a/src/ScadaLink.CentralUI/wwwroot/js/session-expiry.js b/src/ScadaLink.CentralUI/wwwroot/js/session-expiry.js
new file mode 100644
index 0000000..3acc971
--- /dev/null
+++ b/src/ScadaLink.CentralUI/wwwroot/js/session-expiry.js
@@ -0,0 +1,23 @@
+// CentralUI-020: client-side helper for the SessionExpiry component's
+// idle-logout check. Pings the given URL and reports the HTTP status code so
+// the Blazor component can redirect to /login once the server reports 401.
+//
+// `redirect: "manual"` ensures a 302 (should the endpoint ever start
+// redirecting) is reported as an opaque status rather than being followed
+// transparently — the component only ever wants to see the real outcome.
+export async function ping(url) {
+ try {
+ const resp = await fetch(url, {
+ method: "GET",
+ credentials: "same-origin",
+ cache: "no-store",
+ redirect: "manual",
+ headers: { "X-Requested-With": "XMLHttpRequest" }
+ });
+ return resp.status;
+ } catch {
+ // Network failure: report 0 so the caller treats it as inconclusive
+ // and retries on the next poll rather than logging the user out.
+ return 0;
+ }
+}
diff --git a/tests/ScadaLink.CentralUI.Tests/Auth/AuthPingEndpointTests.cs b/tests/ScadaLink.CentralUI.Tests/Auth/AuthPingEndpointTests.cs
new file mode 100644
index 0000000..6f7ea54
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.Tests/Auth/AuthPingEndpointTests.cs
@@ -0,0 +1,90 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+using ScadaLink.CentralUI.Auth;
+
+namespace ScadaLink.CentralUI.Tests.Auth;
+
+///
+/// Regression tests for CentralUI-020. The Blazor circuit's
+/// CookieAuthenticationStateProvider serves a frozen constructor-time
+/// principal, so SessionExpiry could never observe a server-side cookie
+/// expiry by polling the auth state. The fix adds GET /auth/ping, 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 SessionExpiry a real signal to redirect on.
+///
+public class AuthPingEndpointTests
+{
+ private static IReadOnlyList BuildEndpoints()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.Services.AddRouting();
+ builder.Services.AddAntiforgery();
+ var app = builder.Build();
+ app.MapAuthEndpoints();
+
+ return ((IEndpointRouteBuilder)app).DataSources
+ .SelectMany(ds => ds.Endpoints)
+ .OfType()
+ .ToList();
+ }
+
+ private static RouteEndpoint? Find(IReadOnlyList endpoints, string pattern, string method)
+ => endpoints.FirstOrDefault(e =>
+ e.RoutePattern.RawText == pattern &&
+ (e.Metadata.GetMetadata()?.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();
+ Assert.Empty(authorize);
+ }
+}
diff --git a/tests/ScadaLink.CentralUI.Tests/Auth/ClaimsPrincipalExtensionsTests.cs b/tests/ScadaLink.CentralUI.Tests/Auth/ClaimsPrincipalExtensionsTests.cs
new file mode 100644
index 0000000..647fe53
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.Tests/Auth/ClaimsPrincipalExtensionsTests.cs
@@ -0,0 +1,88 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Components.Authorization;
+using ScadaLink.CentralUI.Auth;
+using ScadaLink.Security;
+
+namespace ScadaLink.CentralUI.Tests.Auth;
+
+///
+/// Regression tests for CentralUI-024. Ten components each copy-pasted a
+/// GetCurrentUserAsync helper using the magic string
+/// FindFirst("Username"), and NavMenu/Dashboard used
+/// FindFirst("DisplayName"). A rename of the claim type in
+/// (the single source of truth) would have
+/// silently broken every call site. The shared
+/// helpers now resolve the claim type
+/// through the JwtTokenService constants.
+///
+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 GetAuthenticationStateAsync()
+ => Task.FromResult(_state);
+ }
+}
diff --git a/tests/ScadaLink.CentralUI.Tests/Auth/SessionExpiryComponentTests.cs b/tests/ScadaLink.CentralUI.Tests/Auth/SessionExpiryComponentTests.cs
new file mode 100644
index 0000000..250470a
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.Tests/Auth/SessionExpiryComponentTests.cs
@@ -0,0 +1,88 @@
+using Bunit;
+using Bunit.TestDoubles;
+using Microsoft.AspNetCore.Components;
+using Microsoft.Extensions.DependencyInjection;
+using ScadaLink.CentralUI.Components.Shared;
+
+namespace ScadaLink.CentralUI.Tests.Auth;
+
+///
+/// Regression tests for CentralUI-020 and CentralUI-025. SessionExpiry
+/// used to poll the Blazor AuthenticationStateProvider, which (via
+/// CookieAuthenticationStateProvider) 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
+/// GET /auth/ping endpoint, which reflects the live cookie session: a
+/// 401 response triggers a redirect to /login. These tests exercise that
+/// redirect path directly (CentralUI-025: the path was previously untested).
+///
+public class SessionExpiryComponentTests : BunitContext
+{
+ private const string ModulePath = "./_content/ScadaLink.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("ping", "/auth/ping").SetResult(401);
+
+ var nav = Services.GetRequiredService();
+ var cut = Render();
+
+ 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("ping", "/auth/ping").SetResult(200);
+
+ var nav = Services.GetRequiredService();
+ var before = nav.Uri;
+ var cut = Render();
+
+ 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("ping", "/auth/ping").SetResult(0);
+
+ var nav = Services.GetRequiredService();
+ var before = nav.Uri;
+ var cut = Render();
+
+ 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();
+ nav.NavigateTo("login");
+
+ var cut = Render();
+ await cut.InvokeAsync(() => cut.Instance.CheckSessionAsync());
+
+ // No JS module import was attempted and the URL is unchanged.
+ Assert.EndsWith("/login", nav.Uri);
+ }
+}
diff --git a/tests/ScadaLink.CentralUI.Tests/Deployment/DebugViewStreamRaceTests.cs b/tests/ScadaLink.CentralUI.Tests/Deployment/DebugViewStreamRaceTests.cs
new file mode 100644
index 0000000..f7a8937
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.Tests/Deployment/DebugViewStreamRaceTests.cs
@@ -0,0 +1,153 @@
+using System.Collections;
+using System.Reflection;
+using System.Security.Claims;
+using Bunit;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using NSubstitute;
+using ScadaLink.CentralUI.Auth;
+using ScadaLink.Commons.Entities.Sites;
+using ScadaLink.Commons.Interfaces.Repositories;
+using ScadaLink.Commons.Messages.Streaming;
+using ScadaLink.Communication;
+using ScadaLink.Communication.Grpc;
+using DebugViewPage = ScadaLink.CentralUI.Components.Pages.Deployment.DebugView;
+
+namespace ScadaLink.CentralUI.Tests.Deployment;
+
+///
+/// Regression tests for CentralUI-021. The DebugView stream callback runs
+/// on an Akka/gRPC thread; it used to call UpsertWithCap directly on that
+/// thread, mutating the _attributeValues/_alarmStates
+/// while the render thread enumerated the
+/// same dictionaries via FilteredAttributeValues. Dictionary is
+/// not thread-safe, so the write could throw "Collection was modified" or
+/// corrupt the buckets. The fix routes the callback through
+/// HandleStreamEvent, which marshals the mutation onto the renderer's
+/// dispatcher so every dictionary access happens on one thread.
+///
+public class DebugViewStreamRaceTests : BunitContext
+{
+ private IRenderedComponent RenderPage()
+ {
+ JSInterop.Mode = JSRuntimeMode.Loose;
+
+ var repo = Substitute.For();
+ var siteRepo = Substitute.For();
+ siteRepo.GetAllSitesAsync().Returns(new List());
+ Services.AddSingleton(repo);
+ Services.AddSingleton(siteRepo);
+
+ var comms = new CommunicationService(
+ Options.Create(new CommunicationOptions()),
+ NullLogger.Instance);
+ Services.AddSingleton(comms);
+
+ var grpcFactory = new SiteStreamGrpcClientFactory(NullLoggerFactory.Instance);
+ var debugStream = new DebugStreamService(
+ comms, Services.BuildServiceProvider(), grpcFactory,
+ NullLogger.Instance);
+ Services.AddSingleton(debugStream);
+
+ var identity = new ClaimsIdentity(
+ new[] { new Claim(ClaimTypes.Name, "deployer") }, "TestCookie");
+ var stubAuth = new StubAuthStateProvider(
+ new AuthenticationState(new ClaimsPrincipal(identity)));
+ Services.AddSingleton(stubAuth);
+ Services.AddScoped(_ => new SiteScopeService(stubAuth));
+
+ return Render();
+ }
+
+ private sealed class StubAuthStateProvider : AuthenticationStateProvider
+ {
+ private readonly AuthenticationState _state;
+ public StubAuthStateProvider(AuthenticationState state) => _state = state;
+ public override Task GetAuthenticationStateAsync()
+ => Task.FromResult(_state);
+ }
+
+ private static MethodInfo HandleStreamEvent => typeof(DebugViewPage).GetMethod(
+ "HandleStreamEvent", BindingFlags.Instance | BindingFlags.NonPublic)!;
+
+ private static IDictionary AttributeValues(DebugViewPage c) => (IDictionary)
+ typeof(DebugViewPage).GetField("_attributeValues",
+ BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(c)!;
+
+ private static IEnumerable FilteredAttributeValues(DebugViewPage c) => (IEnumerable)
+ typeof(DebugViewPage).GetProperty("FilteredAttributeValues",
+ BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(c)!;
+
+ [Fact]
+ public void HandleStreamEvent_AppliesUpdate_OnceDispatcherRuns()
+ {
+ // The fix defers the mutation onto the dispatcher — it must not drop it.
+ var cut = RenderPage();
+ var dict = AttributeValues(cut.Instance);
+
+ var evt = new AttributeValueChanged(
+ "Inst-1", "Pump.Speed", "Speed", 42, "Good", DateTimeOffset.UtcNow);
+ HandleStreamEvent.Invoke(cut.Instance, new object[] { evt });
+
+ cut.WaitForState(() => dict.Count == 1, TimeSpan.FromSeconds(2));
+ Assert.True(dict.Contains("Speed"));
+ }
+
+ [Fact]
+ public async Task HandleStreamEvent_OffThreadEvents_DoNotFaultDispatcherReads()
+ {
+ // CentralUI-021 reproduction. Writers fire stream events from background
+ // threads (the Akka/gRPC callback threads). The reader enumerates
+ // FilteredAttributeValues *through the renderer's dispatcher* — exactly
+ // as the real render thread does. Pre-fix the writers mutated the
+ // Dictionary directly on their own threads, racing the dispatcher-side
+ // enumeration and intermittently throwing "Collection was modified".
+ // Post-fix every write is marshalled onto the dispatcher, so writes and
+ // reads are serialised on one thread and the enumeration never faults.
+ var cut = RenderPage();
+ var dict = AttributeValues(cut.Instance);
+
+ Exception? failure = null;
+ using var stop = new CancellationTokenSource();
+
+ var writers = Enumerable.Range(0, 4).Select(w => Task.Run(() =>
+ {
+ try
+ {
+ for (var i = 0; i < 600 && !stop.IsCancellationRequested; i++)
+ {
+ var evt = new AttributeValueChanged(
+ "Inst-1", $"Tag.{w}.{i}", $"Tag-{w}-{i}",
+ i, "Good", DateTimeOffset.UtcNow);
+ HandleStreamEvent.Invoke(cut.Instance, new object[] { evt });
+ }
+ }
+ catch (Exception ex) { failure ??= ex; stop.Cancel(); }
+ })).ToArray();
+
+ var reader = Task.Run(async () =>
+ {
+ try
+ {
+ while (!stop.IsCancellationRequested)
+ {
+ await cut.InvokeAsync(() =>
+ {
+ foreach (var _ in FilteredAttributeValues(cut.Instance)) { }
+ });
+ }
+ }
+ catch (Exception ex) { failure ??= ex; stop.Cancel(); }
+ });
+
+ await Task.WhenAll(writers);
+ stop.Cancel();
+ await reader.WaitAsync(TimeSpan.FromSeconds(5));
+
+ Assert.Null(failure);
+ // Sanity: events were actually delivered (cap is honoured separately).
+ cut.WaitForState(() => dict.Count > 0, TimeSpan.FromSeconds(2));
+ }
+}
diff --git a/tests/ScadaLink.CentralUI.Tests/Deployment/DeploymentsPushUpdateTests.cs b/tests/ScadaLink.CentralUI.Tests/Deployment/DeploymentsPushUpdateTests.cs
index 612af87..2409ce9 100644
--- a/tests/ScadaLink.CentralUI.Tests/Deployment/DeploymentsPushUpdateTests.cs
+++ b/tests/ScadaLink.CentralUI.Tests/Deployment/DeploymentsPushUpdateTests.cs
@@ -109,4 +109,48 @@ public class DeploymentsPushUpdateTests : BunitContext
_deployRepo.DidNotReceive()
.GetAllDeploymentRecordsAsync(Arg.Any());
}
+
+ ///
+ /// Regression test for CentralUI-022. The notifier is a process singleton:
+ /// it can read its subscriber list and begin invoking
+ /// OnDeploymentStatusChanged on the DeploymentManager thread an
+ /// instant before the component is disposed. The handler must no-op against
+ /// a disposed component rather than letting InvokeAsync throw an
+ /// unobserved .
+ ///
+ [Fact]
+ public void Deployments_HasDisposalGuardField()
+ {
+ var field = typeof(DeploymentsPage).GetField(
+ "_disposed", BindingFlags.Instance | BindingFlags.NonPublic);
+
+ Assert.NotNull(field);
+ Assert.Equal(typeof(bool), field!.FieldType);
+ }
+
+ [Fact]
+ public void Deployments_StatusChangeAfterDispose_DoesNotThrowOrReload()
+ {
+ RegisterServices();
+ var cut = Render();
+ var component = cut.Instance;
+
+ component.Dispose();
+ _deployRepo.ClearReceivedCalls();
+
+ // Simulate the race: the notifier captured the handler before the
+ // Dispose() unsubscribe and invokes it directly against the now-disposed
+ // component. Pre-fix this dispatched InvokeAsync against a dead circuit
+ // and threw ObjectDisposedException on a fire-and-forget task.
+ var handler = typeof(DeploymentsPage).GetMethod(
+ "OnDeploymentStatusChanged", BindingFlags.Instance | BindingFlags.NonPublic)!;
+
+ var ex = Record.Exception(() => handler.Invoke(component,
+ new object[] { new DeploymentStatusChange("dep-9", 1, DeploymentStatus.Success) }));
+
+ Assert.Null(ex);
+ // The guard short-circuits before any reload is attempted.
+ _deployRepo.DidNotReceive()
+ .GetAllDeploymentRecordsAsync(Arg.Any());
+ }
}
diff --git a/tests/ScadaLink.CentralUI.Tests/Shared/DiffDialogTests.cs b/tests/ScadaLink.CentralUI.Tests/Shared/DiffDialogTests.cs
index 84918d6..5c742a7 100644
--- a/tests/ScadaLink.CentralUI.Tests/Shared/DiffDialogTests.cs
+++ b/tests/ScadaLink.CentralUI.Tests/Shared/DiffDialogTests.cs
@@ -13,9 +13,23 @@ namespace ScadaLink.CentralUI.Tests.Shared;
///
public class DiffDialogTests : BunitContext
{
+ ///
+ /// DiffDialog applies/removes a body scroll-lock class via JS interop on
+ /// open/close. CentralUI-023 narrowed those catch blocks so they no longer
+ /// swallow every exception — including bUnit's strict-mode unplanned-call
+ /// exception. Tests that exercise open/close must therefore register the
+ /// body-class calls so they do not surface as harness exceptions.
+ ///
+ private void SetupBodyLockInterop()
+ {
+ JSInterop.SetupVoid("document.body.classList.add", "modal-open");
+ JSInterop.SetupVoid("document.body.classList.remove", "modal-open");
+ }
+
[Fact]
public async Task DisposeAsync_WhileOpen_CompletesPendingTask()
{
+ SetupBodyLockInterop();
var cut = Render();
// Open the dialog; the returned task represents the caller's await.
@@ -41,6 +55,7 @@ public class DiffDialogTests : BunitContext
[Fact]
public async Task Close_CompletesPendingTaskWithTrue()
{
+ SetupBodyLockInterop();
var cut = Render();
// Block-bodied lambda so InvokeAsync sees a void delegate — it must NOT
diff --git a/tests/ScadaLink.CentralUI.Tests/Shared/JsInteropLoggingTests.cs b/tests/ScadaLink.CentralUI.Tests/Shared/JsInteropLoggingTests.cs
new file mode 100644
index 0000000..b8f3e27
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.Tests/Shared/JsInteropLoggingTests.cs
@@ -0,0 +1,142 @@
+using System.Reflection;
+using System.Security.Claims;
+using Bunit;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using Microsoft.JSInterop;
+using NSubstitute;
+using ScadaLink.CentralUI.Auth;
+using ScadaLink.CentralUI.Components.Shared;
+using ScadaLink.Commons.Entities.Sites;
+using ScadaLink.Commons.Interfaces.Repositories;
+using ScadaLink.Communication;
+using ParkedMessagesPage = ScadaLink.CentralUI.Components.Pages.Monitoring.ParkedMessages;
+
+namespace ScadaLink.CentralUI.Tests.Shared;
+
+///
+/// Regression tests for CentralUI-023. DiffDialog.TryLockBodyAsync /
+/// TryUnlockBodyAsync and ParkedMessages.CopyAsync wrapped JS
+/// interop in bare catch { } blocks: a genuine
+/// was indistinguishable from an expected
+/// and neither was logged. The fix narrows the catch and logs real interop
+/// failures via ILogger, consistent with the CentralUI-018 fixes.
+///
+public class JsInteropLoggingTests : BunitContext
+{
+ /// Captures log entries so the test can assert on them.
+ private sealed class CapturingLoggerProvider : ILoggerProvider
+ {
+ public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new();
+
+ public ILogger CreateLogger(string categoryName) => new CapturingLogger(Entries);
+ public void Dispose() { }
+
+ private sealed class CapturingLogger : ILogger
+ {
+ private readonly List<(LogLevel, string, Exception?)> _entries;
+ public CapturingLogger(List<(LogLevel, string, Exception?)> entries) => _entries = entries;
+
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ public void Log(LogLevel logLevel, EventId eventId, TState state,
+ Exception? exception, Func formatter)
+ => _entries.Add((logLevel, formatter(state, exception), exception));
+ }
+ }
+
+ [Fact]
+ public void DiffDialog_BodyLock_GenuineJsException_IsLogged()
+ {
+ var provider = new CapturingLoggerProvider();
+ Services.AddLogging(b => b.AddProvider(provider));
+
+ // The body scroll-lock runs on OnAfterRender when the dialog is shown.
+ // Configure that JS call to throw a genuine JSException.
+ JSInterop.Mode = JSRuntimeMode.Strict;
+ JSInterop.SetupVoid("document.body.classList.add", "modal-open")
+ .SetException(new JSException("body lock failed"));
+ // Focus and any other interop is harmless here — allow it loosely.
+ JSInterop.SetupVoid("document.body.classList.remove", "modal-open");
+
+ var cut = Render();
+ cut.InvokeAsync(() => cut.Instance.ShowAsync("Compare", "a", "b"));
+ cut.Render();
+
+ cut.WaitForAssertion(() =>
+ {
+ var warnings = provider.Entries.Where(e => e.Level >= LogLevel.Warning).ToList();
+ Assert.Contains(warnings, e => e.Exception is JSException);
+ });
+ }
+
+ [Fact]
+ public void DiffDialog_BodyLock_Disconnect_IsNotLogged()
+ {
+ var provider = new CapturingLoggerProvider();
+ Services.AddLogging(b => b.AddProvider(provider));
+
+ // A circuit disconnect during the lock is expected — it must NOT log.
+ JSInterop.Mode = JSRuntimeMode.Strict;
+ JSInterop.SetupVoid("document.body.classList.add", "modal-open")
+ .SetException(new JSDisconnectedException("circuit gone"));
+ JSInterop.SetupVoid("document.body.classList.remove", "modal-open");
+
+ var cut = Render();
+ cut.InvokeAsync(() => cut.Instance.ShowAsync("Compare", "a", "b"));
+ cut.Render();
+
+ Assert.DoesNotContain(provider.Entries, e => e.Level >= LogLevel.Warning);
+ }
+
+ [Fact]
+ public async Task ParkedMessages_Copy_GenuineJsException_IsLogged()
+ {
+ var provider = new CapturingLoggerProvider();
+ Services.AddLogging(b => b.AddProvider(provider));
+
+ var siteRepo = Substitute.For();
+ siteRepo.GetAllSitesAsync().Returns(new List());
+ Services.AddSingleton(siteRepo);
+
+ var comms = new CommunicationService(
+ Options.Create(new CommunicationOptions()),
+ NullLogger.Instance);
+ Services.AddSingleton(comms);
+
+ var identity = new ClaimsIdentity(
+ new[] { new Claim(ClaimTypes.Name, "deployer") }, "TestCookie");
+ var stubAuth = new StubAuthStateProvider(
+ new AuthenticationState(new ClaimsPrincipal(identity)));
+ Services.AddSingleton(stubAuth);
+ Services.AddScoped(_ => new SiteScopeService(stubAuth));
+ Services.AddScoped();
+
+ JSInterop.Mode = JSRuntimeMode.Strict;
+ JSInterop.SetupVoid("navigator.clipboard.writeText", _ => true)
+ .SetException(new JSException("clipboard permission denied"));
+
+ var cut = Render();
+
+ // CopyAsync is a private handler; invoke it directly with a clipboard
+ // call configured to fail. Pre-fix the bare catch swallowed it silently.
+ var copy = typeof(ParkedMessagesPage).GetMethod(
+ "CopyAsync", BindingFlags.Instance | BindingFlags.NonPublic)!;
+ await cut.InvokeAsync(() => (Task)copy.Invoke(cut.Instance, new object[] { "some-id" })!);
+
+ var warnings = provider.Entries.Where(e => e.Level >= LogLevel.Warning).ToList();
+ Assert.Contains(warnings, e => e.Exception is JSException);
+ }
+
+ private sealed class StubAuthStateProvider : AuthenticationStateProvider
+ {
+ private readonly AuthenticationState _state;
+ public StubAuthStateProvider(AuthenticationState state) => _state = state;
+ public override Task GetAuthenticationStateAsync()
+ => Task.FromResult(_state);
+ }
+}