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:
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