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:
@@ -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()
|
||||
.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>
|
||||
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]
|
||||
public async Task DisposeAsync_WhileOpen_CompletesPendingTask()
|
||||
{
|
||||
SetupBodyLockInterop();
|
||||
var cut = Render<DiffDialog>();
|
||||
|
||||
// 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<DiffDialog>();
|
||||
|
||||
// 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