using System.Reflection; using System.Security.Claims; using Bunit; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.JSInterop; using NSubstitute; using ScadaLink.CentralUI.Auth; using ScadaLink.CentralUI.Components.Shared; using ScadaLink.Commons.Entities.Sites; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Communication; using ParkedMessagesPage = ScadaLink.CentralUI.Components.Pages.Monitoring.ParkedMessages; namespace ScadaLink.CentralUI.Tests.Shared; /// /// Regression tests for CentralUI-023. DiffDialog.TryLockBodyAsync / /// TryUnlockBodyAsync and ParkedMessages.CopyAsync wrapped JS /// interop in bare catch { } blocks: a genuine /// was indistinguishable from an expected /// and neither was logged. The fix narrows the catch and logs real interop /// failures via ILogger, consistent with the CentralUI-018 fixes. /// public class JsInteropLoggingTests : BunitContext { /// Captures log entries so the test can assert on them. private sealed class CapturingLoggerProvider : ILoggerProvider { public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new(); public ILogger CreateLogger(string categoryName) => new CapturingLogger(Entries); public void Dispose() { } private sealed class CapturingLogger : ILogger { private readonly List<(LogLevel, string, Exception?)> _entries; public CapturingLogger(List<(LogLevel, string, Exception?)> entries) => _entries = entries; public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => _entries.Add((logLevel, formatter(state, exception), exception)); } } [Fact] public void DiffDialog_BodyLock_GenuineJsException_IsLogged() { var provider = new CapturingLoggerProvider(); Services.AddLogging(b => b.AddProvider(provider)); // The body scroll-lock runs on OnAfterRender when the dialog is shown. // Configure that JS call to throw a genuine JSException. JSInterop.Mode = JSRuntimeMode.Strict; JSInterop.SetupVoid("document.body.classList.add", "modal-open") .SetException(new JSException("body lock failed")); // Focus and any other interop is harmless here — allow it loosely. JSInterop.SetupVoid("document.body.classList.remove", "modal-open"); var cut = Render(); cut.InvokeAsync(() => cut.Instance.ShowAsync("Compare", "a", "b")); cut.Render(); cut.WaitForAssertion(() => { var warnings = provider.Entries.Where(e => e.Level >= LogLevel.Warning).ToList(); Assert.Contains(warnings, e => e.Exception is JSException); }); } [Fact] public void DiffDialog_BodyLock_Disconnect_IsNotLogged() { var provider = new CapturingLoggerProvider(); Services.AddLogging(b => b.AddProvider(provider)); // A circuit disconnect during the lock is expected — it must NOT log. JSInterop.Mode = JSRuntimeMode.Strict; JSInterop.SetupVoid("document.body.classList.add", "modal-open") .SetException(new JSDisconnectedException("circuit gone")); JSInterop.SetupVoid("document.body.classList.remove", "modal-open"); var cut = Render(); cut.InvokeAsync(() => cut.Instance.ShowAsync("Compare", "a", "b")); cut.Render(); Assert.DoesNotContain(provider.Entries, e => e.Level >= LogLevel.Warning); } [Fact] public async Task ParkedMessages_Copy_GenuineJsException_IsLogged() { var provider = new CapturingLoggerProvider(); Services.AddLogging(b => b.AddProvider(provider)); var siteRepo = Substitute.For(); siteRepo.GetAllSitesAsync().Returns(new List()); Services.AddSingleton(siteRepo); var comms = new CommunicationService( Options.Create(new CommunicationOptions()), NullLogger.Instance); Services.AddSingleton(comms); var identity = new ClaimsIdentity( new[] { new Claim(ClaimTypes.Name, "deployer") }, "TestCookie"); var stubAuth = new StubAuthStateProvider( new AuthenticationState(new ClaimsPrincipal(identity))); Services.AddSingleton(stubAuth); Services.AddScoped(_ => new SiteScopeService(stubAuth)); Services.AddScoped(); JSInterop.Mode = JSRuntimeMode.Strict; JSInterop.SetupVoid("navigator.clipboard.writeText", _ => true) .SetException(new JSException("clipboard permission denied")); var cut = Render(); // CopyAsync is a private handler; invoke it directly with a clipboard // call configured to fail. Pre-fix the bare catch swallowed it silently. var copy = typeof(ParkedMessagesPage).GetMethod( "CopyAsync", BindingFlags.Instance | BindingFlags.NonPublic)!; await cut.InvokeAsync(() => (Task)copy.Invoke(cut.Instance, new object[] { "some-id" })!); var warnings = provider.Entries.Where(e => e.Level >= LogLevel.Warning).ToList(); Assert.Contains(warnings, e => e.Exception is JSException); } private sealed class StubAuthStateProvider : AuthenticationStateProvider { private readonly AuthenticationState _state; public StubAuthStateProvider(AuthenticationState state) => _state = state; public override Task GetAuthenticationStateAsync() => Task.FromResult(_state); } }