fix(central-ui): resolve CentralUI-020..025 — auth-ping idle logout, DebugView race, push-handler disposal guard, JS-interop catch narrowing, claim-constant helper, SessionExpiry tests
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
| Last reviewed | 2026-05-17 |
|
| Last reviewed | 2026-05-17 |
|
||||||
| Reviewer | claude-agent |
|
| Reviewer | claude-agent |
|
||||||
| Commit reviewed | `39d737e` |
|
| Commit reviewed | `39d737e` |
|
||||||
| Open findings | 6 |
|
| Open findings | 0 |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -994,7 +994,7 @@ in the fixture.)
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | High |
|
| Severity | High |
|
||||||
| Category | Concurrency & thread safety |
|
| Category | Concurrency & thread safety |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.CentralUI/Components/Shared/SessionExpiry.razor:39-62`; `src/ScadaLink.CentralUI/Auth/CookieAuthenticationStateProvider.cs:29-43` |
|
| Location | `src/ScadaLink.CentralUI/Components/Shared/SessionExpiry.razor:39-62`; `src/ScadaLink.CentralUI/Auth/CookieAuthenticationStateProvider.cs:29-43` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -1036,7 +1036,7 @@ expired session (see CentralUI-025).
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
2026-05-17 — `SessionExpiry` no longer polls the frozen `AuthenticationStateProvider`; it polls a new anonymous `GET /auth/ping` minimal-API endpoint (re-validated by the cookie middleware on every HTTP request) via a `fetch()` JS helper and redirects to `/login` on HTTP 401, so the documented 30-minute idle logout actually fires.
|
||||||
|
|
||||||
### CentralUI-021 — `DebugView` stream callback mutates `Dictionary` off the render thread
|
### CentralUI-021 — `DebugView` stream callback mutates `Dictionary` off the render thread
|
||||||
|
|
||||||
@@ -1044,7 +1044,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Concurrency & thread safety |
|
| Category | Concurrency & thread safety |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.CentralUI/Components/Pages/Deployment/DebugView.razor:404-419,511-519,275-289` |
|
| Location | `src/ScadaLink.CentralUI/Components/Pages/Deployment/DebugView.razor:404-419,511-519,275-289` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -1075,7 +1075,7 @@ critical section as the upsert.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
2026-05-17 — the stream callback now routes through `HandleStreamEvent`, which marshals the `UpsertWithCap` mutation (and the cap-trim loop) onto the renderer's dispatcher via `SafeInvokeAsync`, so every read and write of `_attributeValues`/`_alarmStates` happens on the render thread.
|
||||||
|
|
||||||
### CentralUI-022 — `Deployments` push handler fires `InvokeAsync` with no disposal guard
|
### CentralUI-022 — `Deployments` push handler fires `InvokeAsync` with no disposal guard
|
||||||
|
|
||||||
@@ -1083,7 +1083,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Error handling & resilience |
|
| Category | Error handling & resilience |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.CentralUI/Components/Pages/Deployment/Deployments.razor:221-229,317-322` |
|
| Location | `src/ScadaLink.CentralUI/Components/Pages/Deployment/Deployments.razor:221-229,317-322` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -1114,7 +1114,7 @@ rather than the whole table on each event.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
2026-05-17 — added a `volatile bool _disposed` set first in `Dispose()`; `OnDeploymentStatusChanged` no-ops when set, and the fire-and-forget dispatch (`DispatchReloadAsync`) swallows the residual `ObjectDisposedException`, mirroring the `DebugView`/`ToastNotification` guards.
|
||||||
|
|
||||||
### CentralUI-023 — Residual bare `catch {}` blocks swallow JS interop errors
|
### CentralUI-023 — Residual bare `catch {}` blocks swallow JS interop errors
|
||||||
|
|
||||||
@@ -1122,7 +1122,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Error handling & resilience |
|
| Category | Error handling & resilience |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.CentralUI/Components/Pages/Monitoring/ParkedMessages.razor:690-698`; `src/ScadaLink.CentralUI/Components/Shared/DiffDialog.razor:107-116,118-130,104` |
|
| Location | `src/ScadaLink.CentralUI/Components/Pages/Monitoring/ParkedMessages.razor:690-698`; `src/ScadaLink.CentralUI/Components/Shared/DiffDialog.razor:107-116,118-130,104` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -1147,7 +1147,7 @@ call, consistent with the CentralUI-018 fixes in the same module.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
2026-05-17 — the bare `catch` blocks in `ParkedMessages.CopyAsync` and `DiffDialog.TryLockBodyAsync`/`TryUnlockBodyAsync`/`OnAfterRenderAsync` now catch `JSDisconnectedException` (and `InvalidOperationException` for prerender focus) silently and log genuine `JSException` failures via injected `ILogger`.
|
||||||
|
|
||||||
### CentralUI-024 — Claim lookups use magic strings instead of `JwtTokenService` constants
|
### CentralUI-024 — Claim lookups use magic strings instead of `JwtTokenService` constants
|
||||||
|
|
||||||
@@ -1155,7 +1155,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Code organization & conventions |
|
| Category | Code organization & conventions |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor:102`; `src/ScadaLink.CentralUI/Components/Pages/Dashboard.razor:14`; `GetCurrentUserAsync` in `Templates.razor`, `TemplateEdit.razor`, `TemplateCreate.razor`, `SharedScripts.razor`, `SharedScriptForm.razor`, `Sites.razor`, `Topology.razor`, `InstanceCreate.razor`, `InstanceConfigure.razor` |
|
| Location | `src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor:102`; `src/ScadaLink.CentralUI/Components/Pages/Dashboard.razor:14`; `GetCurrentUserAsync` in `Templates.razor`, `TemplateEdit.razor`, `TemplateCreate.razor`, `SharedScripts.razor`, `SharedScriptForm.razor`, `Sites.razor`, `Topology.razor`, `InstanceCreate.razor`, `InstanceConfigure.razor` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -1181,7 +1181,7 @@ or a small scoped service) so the claim lookup lives in exactly one place.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
2026-05-17 — added `ClaimsPrincipalExtensions` (`GetUsername`/`GetDisplayName`/`GetCurrentUsernameAsync`) resolving claims through the `JwtTokenService` constants; the ten copy-pasted `GetCurrentUserAsync` helpers and the `NavMenu`/`Dashboard` `DisplayName` lookups now delegate to it, eliminating every magic-string claim literal.
|
||||||
|
|
||||||
### CentralUI-025 — `SessionExpiry` polling/redirect path has no test coverage
|
### CentralUI-025 — `SessionExpiry` polling/redirect path has no test coverage
|
||||||
|
|
||||||
@@ -1189,7 +1189,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Testing coverage |
|
| Category | Testing coverage |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `tests/ScadaLink.CentralUI.Tests/Auth/SessionExpiryPolicyTests.cs`; `src/ScadaLink.CentralUI/Components/Shared/SessionExpiry.razor` |
|
| Location | `tests/ScadaLink.CentralUI.Tests/Auth/SessionExpiryPolicyTests.cs`; `src/ScadaLink.CentralUI/Components/Shared/SessionExpiry.razor` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -1215,4 +1215,4 @@ also forces the CentralUI-020 fix.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
2026-05-17 — added `SessionExpiryComponentTests` (bUnit): an expired ping (401) redirects to `/login`, a live ping (200) and a transient failure (status 0) do not, and on the `/login` route the component neither pings nor redirects; also added `AuthPingEndpointTests` covering the `/auth/ping` endpoint contract.
|
||||||
|
|||||||
@@ -134,9 +134,35 @@ public static class AuthEndpoints
|
|||||||
context.Response.Redirect("/login");
|
context.Response.Redirect("/login");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// CentralUI-020: liveness probe for the client-side idle-logout check.
|
||||||
|
// The Blazor circuit's CookieAuthenticationStateProvider serves a frozen
|
||||||
|
// constructor-time principal (CentralUI-004), so a circuit can never
|
||||||
|
// observe a server-side cookie expiry by polling the auth state.
|
||||||
|
// SessionExpiry instead polls this endpoint via fetch(): being a normal
|
||||||
|
// HTTP request, the cookie middleware re-validates (and slides) the
|
||||||
|
// cookie on every hit. It deliberately does NOT use RequireAuthorization
|
||||||
|
// — that would make the middleware answer a lapsed request with a 302 to
|
||||||
|
// /login, which fetch() follows transparently and reads as a 200 login
|
||||||
|
// page. Allowing anonymous access and returning 200/401 ourselves gives
|
||||||
|
// the client an unambiguous expiry signal.
|
||||||
|
endpoints.MapGet("/auth/ping", HandlePing);
|
||||||
|
|
||||||
return endpoints;
|
return endpoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handler for <c>GET /auth/ping</c>. Returns <c>200</c> while the caller's
|
||||||
|
/// cookie session is still valid and <c>401</c> once it has lapsed
|
||||||
|
/// server-side. See CentralUI-020.
|
||||||
|
/// </summary>
|
||||||
|
public static Task HandlePing(HttpContext context)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = context.User.Identity?.IsAuthenticated == true
|
||||||
|
? StatusCodes.Status200OK
|
||||||
|
: StatusCodes.Status401Unauthorized;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the <see cref="AuthenticationProperties"/> for the login sign-in.
|
/// Builds the <see cref="AuthenticationProperties"/> for the login sign-in.
|
||||||
/// CentralUI-005: deliberately does <b>not</b> set <see cref="AuthenticationProperties.ExpiresUtc"/>.
|
/// CentralUI-005: deliberately does <b>not</b> set <see cref="AuthenticationProperties.ExpiresUtc"/>.
|
||||||
|
|||||||
43
src/ScadaLink.CentralUI/Auth/ClaimsPrincipalExtensions.cs
Normal file
43
src/ScadaLink.CentralUI/Auth/ClaimsPrincipalExtensions.cs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using ScadaLink.Security;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Claim-lookup helpers for the Central UI. CentralUI-024: claim types are owned
|
||||||
|
/// by <see cref="JwtTokenService"/> (the single source of truth). These helpers
|
||||||
|
/// resolve them through the <c>JwtTokenService</c> constants so a rename there
|
||||||
|
/// propagates here instead of silently breaking ten copy-pasted call sites.
|
||||||
|
/// </summary>
|
||||||
|
public static class ClaimsPrincipalExtensions
|
||||||
|
{
|
||||||
|
/// <summary>Fallback returned when no username claim is present.</summary>
|
||||||
|
public const string UnknownUser = "unknown";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The audit username for <paramref name="principal"/>, or
|
||||||
|
/// <see cref="UnknownUser"/> when the claim is absent.
|
||||||
|
/// </summary>
|
||||||
|
public static string GetUsername(this ClaimsPrincipal principal)
|
||||||
|
=> principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? UnknownUser;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The display name for <paramref name="principal"/>, or <c>null</c> when
|
||||||
|
/// the claim is absent.
|
||||||
|
/// </summary>
|
||||||
|
public static string? GetDisplayName(this ClaimsPrincipal principal)
|
||||||
|
=> principal.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the current user's audit username from the auth state provider.
|
||||||
|
/// Replaces the <c>GetCurrentUserAsync</c> helper that was copy-pasted into
|
||||||
|
/// ten components (CentralUI-024).
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<string> GetCurrentUsernameAsync(
|
||||||
|
this AuthenticationStateProvider authStateProvider)
|
||||||
|
{
|
||||||
|
var authState = await authStateProvider.GetAuthenticationStateAsync();
|
||||||
|
return authState.User.GetUsername();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,7 +99,8 @@
|
|||||||
<Authorized>
|
<Authorized>
|
||||||
<div class="border-top border-secondary px-3 py-2">
|
<div class="border-top border-secondary px-3 py-2">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<span class="text-light small">@context.User.FindFirst("DisplayName")?.Value</span>
|
@* CentralUI-024: claim type resolved via JwtTokenService. *@
|
||||||
|
<span class="text-light small">@context.User.GetDisplayName()</span>
|
||||||
<form method="post" action="/auth/logout" data-enhance="false">
|
<form method="post" action="/auth/logout" data-enhance="false">
|
||||||
@* CentralUI-017: logout is a state-changing POST and is
|
@* CentralUI-017: logout is a state-changing POST and is
|
||||||
CSRF-protected — the antiforgery token is required. *@
|
CSRF-protected — the antiforgery token is required. *@
|
||||||
|
|||||||
@@ -160,11 +160,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private async Task<string> GetCurrentUserAsync()
|
// CentralUI-024: delegates to the shared helper so the claim type stays
|
||||||
{
|
// resolved through JwtTokenService rather than a duplicated magic string.
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
private Task<string> GetCurrentUserAsync()
|
||||||
return authState.User.FindFirst("Username")?.Value ?? "unknown";
|
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||||
}
|
|
||||||
|
|
||||||
private List<Site> _sites = new();
|
private List<Site> _sites = new();
|
||||||
private Dictionary<int, List<DataConnection>> _siteConnections = new();
|
private Dictionary<int, List<DataConnection>> _siteConnections = new();
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
<AuthorizeView>
|
<AuthorizeView>
|
||||||
<Authorized>
|
<Authorized>
|
||||||
<span class="text-muted small">
|
<span class="text-muted small">
|
||||||
Signed in as <strong>@context.User.FindFirst("DisplayName")?.Value</strong>
|
@* CentralUI-024: claim type resolved via JwtTokenService. *@
|
||||||
|
Signed in as <strong>@context.User.GetDisplayName()</strong>
|
||||||
</span>
|
</span>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
|
|||||||
@@ -401,23 +401,7 @@
|
|||||||
{
|
{
|
||||||
var session = await DebugStreamService.StartStreamAsync(
|
var session = await DebugStreamService.StartStreamAsync(
|
||||||
_selectedInstanceId,
|
_selectedInstanceId,
|
||||||
onEvent: evt =>
|
onEvent: HandleStreamEvent,
|
||||||
{
|
|
||||||
// CentralUI-009: the component may have been disposed while
|
|
||||||
// this event was in flight on the Akka/gRPC thread.
|
|
||||||
if (_disposed) return;
|
|
||||||
switch (evt)
|
|
||||||
{
|
|
||||||
case AttributeValueChanged av:
|
|
||||||
UpsertWithCap(_attributeValues, av.AttributeName, av);
|
|
||||||
SafeInvokeStateHasChanged();
|
|
||||||
break;
|
|
||||||
case AlarmStateChanged al:
|
|
||||||
UpsertWithCap(_alarmStates, al.AlarmName, al);
|
|
||||||
SafeInvokeStateHasChanged();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onTerminated: () =>
|
onTerminated: () =>
|
||||||
{
|
{
|
||||||
_connected = false;
|
_connected = false;
|
||||||
@@ -503,10 +487,51 @@
|
|||||||
_alarmStates.Clear();
|
_alarmStates.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles one debug-stream event. The callback is invoked on an Akka/gRPC
|
||||||
|
/// thread, but <see cref="_attributeValues"/>/<see cref="_alarmStates"/> are
|
||||||
|
/// <see cref="Dictionary{TKey,TValue}"/> instances also enumerated by the
|
||||||
|
/// render thread via <see cref="FilteredAttributeValues"/>/
|
||||||
|
/// <see cref="FilteredAlarmStates"/>. <c>Dictionary</c> is not thread-safe
|
||||||
|
/// (CentralUI-021): a write racing an enumeration can throw or corrupt the
|
||||||
|
/// buckets. The mutation (<see cref="UpsertWithCap"/>) is therefore
|
||||||
|
/// marshalled onto the renderer's dispatcher via <see cref="SafeInvokeAsync"/>
|
||||||
|
/// so every access to the dictionaries — read and write — happens on the
|
||||||
|
/// render thread.
|
||||||
|
/// </summary>
|
||||||
|
private void HandleStreamEvent(object evt)
|
||||||
|
{
|
||||||
|
// CentralUI-009: the component may have been disposed while this event
|
||||||
|
// was in flight on the Akka/gRPC thread.
|
||||||
|
if (_disposed) return;
|
||||||
|
_ = SafeInvokeAsync(() =>
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
switch (evt)
|
||||||
|
{
|
||||||
|
case AttributeValueChanged av:
|
||||||
|
UpsertWithCap(_attributeValues, av.AttributeName, av);
|
||||||
|
break;
|
||||||
|
case AlarmStateChanged al:
|
||||||
|
UpsertWithCap(_alarmStates, al.AlarmName, al);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Replace or insert a value keyed by name, then trim the oldest entries
|
/// Replace or insert a value keyed by name, then trim the oldest entries
|
||||||
/// (queue-style) so the table size never exceeds MaxRows. Dictionary
|
/// (queue-style) so the table size never exceeds MaxRows. Dictionary
|
||||||
/// preserves insertion order, so the first key is always the oldest.
|
/// preserves insertion order, so the first key is always the oldest.
|
||||||
|
/// <para>
|
||||||
|
/// Must be called on the render thread only (CentralUI-021) — see
|
||||||
|
/// <see cref="HandleStreamEvent"/>. The cap-trim loop is in the same
|
||||||
|
/// critical section as the upsert so the dictionary is never observed
|
||||||
|
/// over-capacity.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void UpsertWithCap<T>(Dictionary<string, T> map, string key, T value)
|
private static void UpsertWithCap<T>(Dictionary<string, T> map, string key, T value)
|
||||||
{
|
{
|
||||||
@@ -577,8 +602,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SafeInvokeStateHasChanged() => _ = SafeInvokeAsync(StateHasChanged);
|
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
// CentralUI-009: mark disposed first so any in-flight stream callback
|
// CentralUI-009: mark disposed first so any in-flight stream callback
|
||||||
|
|||||||
@@ -204,6 +204,17 @@
|
|||||||
private int _totalPages;
|
private int _totalPages;
|
||||||
private const int PageSize = 25;
|
private const int PageSize = 25;
|
||||||
|
|
||||||
|
// CentralUI-022: IDeploymentStatusNotifier is a process singleton that
|
||||||
|
// raises StatusChanged on the DeploymentManager service thread. Dispose()
|
||||||
|
// unsubscribes, but the notifier can read its subscriber list and begin
|
||||||
|
// invoking OnDeploymentStatusChanged just before this component is disposed.
|
||||||
|
// The handler then runs against a disposed component and InvokeAsync throws
|
||||||
|
// ObjectDisposedException as an unobserved fire-and-forget task exception.
|
||||||
|
// This flag (set first in Dispose()) makes a racing callback no-op, and the
|
||||||
|
// dispatch swallows the residual ObjectDisposedException — mirroring the
|
||||||
|
// DebugView (CentralUI-009) and ToastNotification (CentralUI-010) guards.
|
||||||
|
private volatile bool _disposed;
|
||||||
|
|
||||||
// CentralUI-006: deployment status updates are push-based, not polled.
|
// CentralUI-006: deployment status updates are push-based, not polled.
|
||||||
// DeploymentManager raises IDeploymentStatusNotifier.StatusChanged on every
|
// DeploymentManager raises IDeploymentStatusNotifier.StatusChanged on every
|
||||||
// deployment-record status write; this page subscribes to it and reloads,
|
// deployment-record status write; this page subscribes to it and reloads,
|
||||||
@@ -220,12 +231,34 @@
|
|||||||
|
|
||||||
private void OnDeploymentStatusChanged(ScadaLink.DeploymentManager.DeploymentStatusChange change)
|
private void OnDeploymentStatusChanged(ScadaLink.DeploymentManager.DeploymentStatusChange change)
|
||||||
{
|
{
|
||||||
if (!_autoRefresh) return;
|
// CentralUI-022: a callback racing disposal must not touch the component.
|
||||||
_ = InvokeAsync(async () =>
|
if (_disposed || !_autoRefresh) return;
|
||||||
|
_ = DispatchReloadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reloads the deployment table on the renderer's dispatcher, guarded
|
||||||
|
/// against the component being disposed mid-flight (CentralUI-022):
|
||||||
|
/// <c>InvokeAsync</c> throws <see cref="ObjectDisposedException"/> once the
|
||||||
|
/// circuit is gone, and this handler runs fire-and-forget so that exception
|
||||||
|
/// would otherwise go unobserved on the DeploymentManager thread.
|
||||||
|
/// </summary>
|
||||||
|
private async Task DispatchReloadAsync()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
await LoadDataAsync();
|
await InvokeAsync(async () =>
|
||||||
StateHasChanged();
|
{
|
||||||
});
|
if (_disposed) return;
|
||||||
|
await LoadDataAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException)
|
||||||
|
{
|
||||||
|
// Component disposed between the guard and the dispatch — ignore.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ToggleAutoRefresh()
|
private void ToggleAutoRefresh()
|
||||||
@@ -316,8 +349,10 @@
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
// Unsubscribe so a status change after the circuit is gone does not
|
// CentralUI-022: set the guard first so a callback already in flight on
|
||||||
// touch a disposed component (the notifier is a process singleton).
|
// the DeploymentManager thread no-ops, then unsubscribe so no further
|
||||||
|
// status change reaches this disposed component.
|
||||||
|
_disposed = true;
|
||||||
DeploymentStatusNotifier.StatusChanged -= OnDeploymentStatusChanged;
|
DeploymentStatusNotifier.StatusChanged -= OnDeploymentStatusChanged;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -438,11 +438,10 @@
|
|||||||
|
|
||||||
private void GoBack() => NavigationManager.NavigateTo("/deployment/topology");
|
private void GoBack() => NavigationManager.NavigateTo("/deployment/topology");
|
||||||
|
|
||||||
private async Task<string> GetCurrentUserAsync()
|
// CentralUI-024: delegates to the shared helper so the claim type stays
|
||||||
{
|
// resolved through JwtTokenService rather than a duplicated magic string.
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
private Task<string> GetCurrentUserAsync()
|
||||||
return authState.User.FindFirst("Username")?.Value ?? "unknown";
|
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||||
}
|
|
||||||
|
|
||||||
// ── Bindings ────────────────────────────────────────────
|
// ── Bindings ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -157,9 +157,8 @@
|
|||||||
|
|
||||||
private void GoBack() => NavigationManager.NavigateTo("/deployment/topology");
|
private void GoBack() => NavigationManager.NavigateTo("/deployment/topology");
|
||||||
|
|
||||||
private async Task<string> GetCurrentUserAsync()
|
// CentralUI-024: delegates to the shared helper so the claim type stays
|
||||||
{
|
// resolved through JwtTokenService rather than a duplicated magic string.
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
private Task<string> GetCurrentUserAsync()
|
||||||
return authState.User.FindFirst("Username")?.Value ?? "unknown";
|
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -921,9 +921,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> GetCurrentUserAsync()
|
// CentralUI-024: delegates to the shared helper so the claim type stays
|
||||||
{
|
// resolved through JwtTokenService rather than a duplicated magic string.
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
private Task<string> GetCurrentUserAsync()
|
||||||
return authState.User.FindFirst("Username")?.Value ?? "unknown";
|
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,11 +172,10 @@
|
|||||||
private ScriptAnalysis.SandboxRunResult? _runResult;
|
private ScriptAnalysis.SandboxRunResult? _runResult;
|
||||||
private CancellationTokenSource? _runCts;
|
private CancellationTokenSource? _runCts;
|
||||||
|
|
||||||
private async Task<string> GetCurrentUserAsync()
|
// CentralUI-024: delegates to the shared helper so the claim type stays
|
||||||
{
|
// resolved through JwtTokenService rather than a duplicated magic string.
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
private Task<string> GetCurrentUserAsync()
|
||||||
return authState.User.FindFirst("Username")?.Value ?? "unknown";
|
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -101,11 +101,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private async Task<string> GetCurrentUserAsync()
|
// CentralUI-024: delegates to the shared helper so the claim type stays
|
||||||
{
|
// resolved through JwtTokenService rather than a duplicated magic string.
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
private Task<string> GetCurrentUserAsync()
|
||||||
return authState.User.FindFirst("Username")?.Value ?? "unknown";
|
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||||
}
|
|
||||||
|
|
||||||
private List<SharedScript> _scripts = new();
|
private List<SharedScript> _scripts = new();
|
||||||
private bool _loading = true;
|
private bool _loading = true;
|
||||||
|
|||||||
@@ -119,9 +119,8 @@
|
|||||||
NavigationManager.NavigateTo("/design/templates");
|
NavigationManager.NavigateTo("/design/templates");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> GetCurrentUserAsync()
|
// CentralUI-024: delegates to the shared helper so the claim type stays
|
||||||
{
|
// resolved through JwtTokenService rather than a duplicated magic string.
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
private Task<string> GetCurrentUserAsync()
|
||||||
return authState.User.FindFirst("Username")?.Value ?? "unknown";
|
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,11 +218,10 @@
|
|||||||
NavigationManager.NavigateTo("/design/templates");
|
NavigationManager.NavigateTo("/design/templates");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> GetCurrentUserAsync()
|
// CentralUI-024: delegates to the shared helper so the claim type stays
|
||||||
{
|
// resolved through JwtTokenService rather than a duplicated magic string.
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
private Task<string> GetCurrentUserAsync()
|
||||||
return authState.User.FindFirst("Username")?.Value ?? "unknown";
|
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||||
}
|
|
||||||
|
|
||||||
private RenderFragment RenderTemplateDetail() => __builder =>
|
private RenderFragment RenderTemplateDetail() => __builder =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -99,11 +99,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private async Task<string> GetCurrentUserAsync()
|
// CentralUI-024: delegates to the shared helper so the claim type stays
|
||||||
{
|
// resolved through JwtTokenService rather than a duplicated magic string.
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
private Task<string> GetCurrentUserAsync()
|
||||||
return authState.User.FindFirst("Username")?.Value ?? "unknown";
|
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||||
}
|
|
||||||
|
|
||||||
private List<Template> _templates = new();
|
private List<Template> _templates = new();
|
||||||
private List<TemplateFolder> _folders = new();
|
private List<TemplateFolder> _folders = new();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
@inject CommunicationService CommunicationService
|
@inject CommunicationService CommunicationService
|
||||||
@inject IJSRuntime JS
|
@inject IJSRuntime JS
|
||||||
@inject IDialogService Dialog
|
@inject IDialogService Dialog
|
||||||
|
@inject ILogger<ParkedMessages> Logger
|
||||||
|
|
||||||
<div class="container-fluid mt-3">
|
<div class="container-fluid mt-3">
|
||||||
<ToastNotification @ref="_toast" />
|
<ToastNotification @ref="_toast" />
|
||||||
@@ -694,7 +695,18 @@
|
|||||||
await JS.InvokeVoidAsync("navigator.clipboard.writeText", text);
|
await JS.InvokeVoidAsync("navigator.clipboard.writeText", text);
|
||||||
_toast.ShowSuccess("Copied to clipboard.");
|
_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 ──
|
// ── Helpers ──
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
via @ref to display a side-by-side or simple before/after comparison.
|
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). *@
|
z-index ladder follows ConfirmDialog: modal 1055 > backdrop 1040 (toasts at 1090). *@
|
||||||
@inject IJSRuntime JS
|
@inject IJSRuntime JS
|
||||||
|
@inject ILogger<DiffDialog> Logger
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
@if (_visible)
|
@if (_visible)
|
||||||
@@ -101,7 +102,20 @@
|
|||||||
_bodyLocked = true;
|
_bodyLocked = true;
|
||||||
await TryLockBodyAsync();
|
await TryLockBodyAsync();
|
||||||
try { await _modalRef.FocusAsync(); }
|
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");
|
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."); }
|
// Circuit gone — the body scroll lock is moot. Expected, silent.
|
||||||
catch { /* swallow */ }
|
}
|
||||||
|
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");
|
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."); }
|
// Circuit gone — the body scroll lock is moot. Expected, silent.
|
||||||
catch { /* swallow */ }
|
}
|
||||||
|
catch (JSException ex)
|
||||||
|
{
|
||||||
|
Logger.LogWarning(ex, "DiffDialog: failed to remove body scroll lock.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +1,58 @@
|
|||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
// CentralUI-005: session expiry is a sliding window owned by the cookie
|
// CentralUI-005 / CentralUI-020: session expiry is a sliding window owned by
|
||||||
// authentication middleware (ScadaLink.Security AddCookie:
|
// the cookie authentication middleware (ScadaLink.Security AddCookie:
|
||||||
// ExpireTimeSpan = idle timeout, SlidingExpiration = true). An active user's
|
// ExpireTimeSpan = idle timeout, SlidingExpiration = true). An active user's
|
||||||
// cookie is continually renewed; an idle user's cookie lapses after the idle
|
// 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 —
|
// timeout. There is 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.
|
|
||||||
//
|
//
|
||||||
// Instead this component polls the authentication state on a recurring
|
// This component must NOT poll the Blazor AuthenticationStateProvider:
|
||||||
// interval. While the session is still valid it does nothing; once the
|
// CookieAuthenticationStateProvider serves a frozen constructor-time
|
||||||
// sliding cookie has expired (the server-side idle cutoff has been reached)
|
// principal for the whole circuit (CentralUI-004), so the polled auth state
|
||||||
// the next poll observes an unauthenticated principal and redirects to the
|
// can never transition to "expired" and the redirect would never fire
|
||||||
// login page. Re-checking the state is itself circuit activity, so this poll
|
// (CentralUI-020).
|
||||||
// alone never keeps a truly idle session alive — only genuine user activity
|
//
|
||||||
// refreshes the cookie before it lapses.
|
// 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.)
|
||||||
|
|
||||||
|
/// <summary>Server endpoint that reports live session validity.</summary>
|
||||||
|
internal const string PingUrl = "/auth/ping";
|
||||||
|
|
||||||
|
/// <summary>HTTP status returned by <see cref="PingUrl"/> once the cookie has lapsed.</summary>
|
||||||
|
private const int Unauthorized = 401;
|
||||||
|
|
||||||
|
private const string ModulePath = "./_content/ScadaLink.CentralUI/js/session-expiry.js";
|
||||||
|
|
||||||
/// <summary>How often the session validity is re-checked.</summary>
|
/// <summary>How often the session validity is re-checked.</summary>
|
||||||
internal static readonly TimeSpan PollInterval = TimeSpan.FromMinutes(1);
|
internal static readonly TimeSpan PollInterval = TimeSpan.FromMinutes(1);
|
||||||
|
|
||||||
private CancellationTokenSource? _cts;
|
private CancellationTokenSource? _cts;
|
||||||
|
private IJSObjectReference? _module;
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
// The login page uses the same layout, so this component renders there
|
// The login page uses the same layout, so this component renders there
|
||||||
// too. Polling/redirecting on /login → /login would loop.
|
// too. Polling/redirecting on /login → /login would loop.
|
||||||
var path = Navigation.ToBaseRelativePath(Navigation.Uri);
|
if (IsOnLoginPage) return;
|
||||||
if (path.StartsWith("login", StringComparison.OrdinalIgnoreCase)) return;
|
|
||||||
|
|
||||||
_cts = new CancellationTokenSource();
|
_cts = new CancellationTokenSource();
|
||||||
_ = PollSessionAsync(_cts.Token);
|
_ = PollSessionAsync(_cts.Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool IsOnLoginPage =>
|
||||||
|
Navigation.ToBaseRelativePath(Navigation.Uri)
|
||||||
|
.StartsWith("login", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private async Task PollSessionAsync(CancellationToken token)
|
private async Task PollSessionAsync(CancellationToken token)
|
||||||
{
|
{
|
||||||
while (!token.IsCancellationRequested)
|
while (!token.IsCancellationRequested)
|
||||||
@@ -43,21 +60,43 @@
|
|||||||
try { await Task.Delay(PollInterval, token); }
|
try { await Task.Delay(PollInterval, token); }
|
||||||
catch (TaskCanceledException) { return; }
|
catch (TaskCanceledException) { return; }
|
||||||
|
|
||||||
AuthenticationState auth;
|
if (token.IsCancellationRequested) return;
|
||||||
try
|
await CheckSessionAsync();
|
||||||
{
|
}
|
||||||
auth = await AuthStateProvider.GetAuthenticationStateAsync();
|
}
|
||||||
}
|
|
||||||
catch (ObjectDisposedException)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (auth.User.Identity?.IsAuthenticated != true)
|
/// <summary>
|
||||||
{
|
/// Runs one liveness check: pings the server and, if the session has lapsed
|
||||||
await InvokeAsync(() => Navigation.NavigateTo("/login", forceLoad: true));
|
/// server-side (HTTP 401), redirects to the login page. Exposed for tests
|
||||||
return;
|
/// (CentralUI-025) so the redirect path can be exercised without waiting on
|
||||||
}
|
/// the poll interval.
|
||||||
|
/// </summary>
|
||||||
|
internal async Task CheckSessionAsync()
|
||||||
|
{
|
||||||
|
if (IsOnLoginPage) return;
|
||||||
|
|
||||||
|
int status;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_module ??= await JS.InvokeAsync<IJSObjectReference>("import", ModulePath);
|
||||||
|
status = await _module.InvokeAsync<int>("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?.Cancel();
|
||||||
_cts?.Dispose();
|
_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 */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,5 +8,6 @@
|
|||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||||
@using ScadaLink.CentralUI
|
@using ScadaLink.CentralUI
|
||||||
|
@using ScadaLink.CentralUI.Auth
|
||||||
@using ScadaLink.CentralUI.Components.Layout
|
@using ScadaLink.CentralUI.Components.Layout
|
||||||
@using ScadaLink.CentralUI.Components.Shared
|
@using ScadaLink.CentralUI.Components.Shared
|
||||||
|
|||||||
23
src/ScadaLink.CentralUI/wwwroot/js/session-expiry.js
Normal file
23
src/ScadaLink.CentralUI/wwwroot/js/session-expiry.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <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();
|
||||||
|
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 ScadaLink.CentralUI.Auth;
|
||||||
|
using ScadaLink.Security;
|
||||||
|
|
||||||
|
namespace ScadaLink.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,88 @@
|
|||||||
|
using Bunit;
|
||||||
|
using Bunit.TestDoubles;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ScadaLink.CentralUI.Components.Shared;
|
||||||
|
|
||||||
|
namespace ScadaLink.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/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<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,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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression tests for CentralUI-021. The <c>DebugView</c> stream callback runs
|
||||||
|
/// on an Akka/gRPC thread; it used to call <c>UpsertWithCap</c> directly on that
|
||||||
|
/// thread, mutating the <c>_attributeValues</c>/<c>_alarmStates</c>
|
||||||
|
/// <see cref="Dictionary{TKey,TValue}"/> while the render thread enumerated the
|
||||||
|
/// same dictionaries via <c>FilteredAttributeValues</c>. <c>Dictionary</c> is
|
||||||
|
/// not thread-safe, so the write could throw "Collection was modified" or
|
||||||
|
/// corrupt the buckets. The fix routes the callback through
|
||||||
|
/// <c>HandleStreamEvent</c>, which marshals the mutation onto the renderer's
|
||||||
|
/// dispatcher so every dictionary access happens on one thread.
|
||||||
|
/// </summary>
|
||||||
|
public class DebugViewStreamRaceTests : BunitContext
|
||||||
|
{
|
||||||
|
private IRenderedComponent<DebugViewPage> RenderPage()
|
||||||
|
{
|
||||||
|
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||||
|
|
||||||
|
var repo = Substitute.For<ITemplateEngineRepository>();
|
||||||
|
var siteRepo = Substitute.For<ISiteRepository>();
|
||||||
|
siteRepo.GetAllSitesAsync().Returns(new List<Site>());
|
||||||
|
Services.AddSingleton(repo);
|
||||||
|
Services.AddSingleton(siteRepo);
|
||||||
|
|
||||||
|
var comms = new CommunicationService(
|
||||||
|
Options.Create(new CommunicationOptions()),
|
||||||
|
NullLogger<CommunicationService>.Instance);
|
||||||
|
Services.AddSingleton(comms);
|
||||||
|
|
||||||
|
var grpcFactory = new SiteStreamGrpcClientFactory(NullLoggerFactory.Instance);
|
||||||
|
var debugStream = new DebugStreamService(
|
||||||
|
comms, Services.BuildServiceProvider(), grpcFactory,
|
||||||
|
NullLogger<DebugStreamService>.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<AuthenticationStateProvider>(stubAuth);
|
||||||
|
Services.AddScoped(_ => new SiteScopeService(stubAuth));
|
||||||
|
|
||||||
|
return Render<DebugViewPage>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubAuthStateProvider : AuthenticationStateProvider
|
||||||
|
{
|
||||||
|
private readonly AuthenticationState _state;
|
||||||
|
public StubAuthStateProvider(AuthenticationState state) => _state = state;
|
||||||
|
public override Task<AuthenticationState> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,4 +109,48 @@ public class DeploymentsPushUpdateTests : BunitContext
|
|||||||
_deployRepo.DidNotReceive()
|
_deployRepo.DidNotReceive()
|
||||||
.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>());
|
.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression test for CentralUI-022. The notifier is a process singleton:
|
||||||
|
/// it can read its subscriber list and begin invoking
|
||||||
|
/// <c>OnDeploymentStatusChanged</c> on the DeploymentManager thread an
|
||||||
|
/// instant before the component is disposed. The handler must no-op against
|
||||||
|
/// a disposed component rather than letting <c>InvokeAsync</c> throw an
|
||||||
|
/// unobserved <see cref="ObjectDisposedException"/>.
|
||||||
|
/// </summary>
|
||||||
|
[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<DeploymentsPage>();
|
||||||
|
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<CancellationToken>());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,23 @@ namespace ScadaLink.CentralUI.Tests.Shared;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class DiffDialogTests : BunitContext
|
public class DiffDialogTests : BunitContext
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private void SetupBodyLockInterop()
|
||||||
|
{
|
||||||
|
JSInterop.SetupVoid("document.body.classList.add", "modal-open");
|
||||||
|
JSInterop.SetupVoid("document.body.classList.remove", "modal-open");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DisposeAsync_WhileOpen_CompletesPendingTask()
|
public async Task DisposeAsync_WhileOpen_CompletesPendingTask()
|
||||||
{
|
{
|
||||||
|
SetupBodyLockInterop();
|
||||||
var cut = Render<DiffDialog>();
|
var cut = Render<DiffDialog>();
|
||||||
|
|
||||||
// Open the dialog; the returned task represents the caller's await.
|
// Open the dialog; the returned task represents the caller's await.
|
||||||
@@ -41,6 +55,7 @@ public class DiffDialogTests : BunitContext
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Close_CompletesPendingTaskWithTrue()
|
public async Task Close_CompletesPendingTaskWithTrue()
|
||||||
{
|
{
|
||||||
|
SetupBodyLockInterop();
|
||||||
var cut = Render<DiffDialog>();
|
var cut = Render<DiffDialog>();
|
||||||
|
|
||||||
// Block-bodied lambda so InvokeAsync sees a void delegate — it must NOT
|
// Block-bodied lambda so InvokeAsync sees a void delegate — it must NOT
|
||||||
|
|||||||
142
tests/ScadaLink.CentralUI.Tests/Shared/JsInteropLoggingTests.cs
Normal file
142
tests/ScadaLink.CentralUI.Tests/Shared/JsInteropLoggingTests.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression tests for CentralUI-023. <c>DiffDialog.TryLockBodyAsync</c> /
|
||||||
|
/// <c>TryUnlockBodyAsync</c> and <c>ParkedMessages.CopyAsync</c> wrapped JS
|
||||||
|
/// interop in bare <c>catch { }</c> blocks: a genuine <see cref="JSException"/>
|
||||||
|
/// was indistinguishable from an expected <see cref="JSDisconnectedException"/>
|
||||||
|
/// and neither was logged. The fix narrows the catch and logs real interop
|
||||||
|
/// failures via <c>ILogger</c>, consistent with the CentralUI-018 fixes.
|
||||||
|
/// </summary>
|
||||||
|
public class JsInteropLoggingTests : BunitContext
|
||||||
|
{
|
||||||
|
/// <summary>Captures log entries so the test can assert on them.</summary>
|
||||||
|
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>(TState state) where TState : notnull => null;
|
||||||
|
public bool IsEnabled(LogLevel logLevel) => true;
|
||||||
|
|
||||||
|
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
|
||||||
|
Exception? exception, Func<TState, Exception?, string> 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<DiffDialog>();
|
||||||
|
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<DiffDialog>();
|
||||||
|
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<ISiteRepository>();
|
||||||
|
siteRepo.GetAllSitesAsync().Returns(new List<Site>());
|
||||||
|
Services.AddSingleton(siteRepo);
|
||||||
|
|
||||||
|
var comms = new CommunicationService(
|
||||||
|
Options.Create(new CommunicationOptions()),
|
||||||
|
NullLogger<CommunicationService>.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<AuthenticationStateProvider>(stubAuth);
|
||||||
|
Services.AddScoped(_ => new SiteScopeService(stubAuth));
|
||||||
|
Services.AddScoped<IDialogService, DialogService>();
|
||||||
|
|
||||||
|
JSInterop.Mode = JSRuntimeMode.Strict;
|
||||||
|
JSInterop.SetupVoid("navigator.clipboard.writeText", _ => true)
|
||||||
|
.SetException(new JSException("clipboard permission denied"));
|
||||||
|
|
||||||
|
var cut = Render<ParkedMessagesPage>();
|
||||||
|
|
||||||
|
// 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<AuthenticationState> GetAuthenticationStateAsync()
|
||||||
|
=> Task.FromResult(_state);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user