Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/JsInteropLoggingTests.cs
T
Joseph Doherty 513fa1aa16 docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
2026-10-07 15:05:49 -04:00

171 lines
8.5 KiB
C#

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 ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Communication;
using ParkedMessagesPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring.ParkedMessages;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Regression tests for JS-interop failure logging. <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 earlier JS-interop logging fixes.
/// </summary>
public class JsInteropLoggingTests : BunitContext
{
/// <summary>Captures log entries so the test can assert on them.</summary>
private sealed class CapturingLoggerProvider : ILoggerProvider
{
/// <summary>Gets the log entries captured by the factory's loggers.</summary>
public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new();
/// <summary>Creates a logger that records into the shared entry list.</summary>
/// <param name="categoryName">The logger category name, which is ignored.</param>
/// <returns>A logger that captures its entries.</returns>
public ILogger CreateLogger(string categoryName) => new CapturingLogger(Entries);
/// <summary>Does nothing; there is nothing to release.</summary>
public void Dispose() { }
private sealed class CapturingLogger : ILogger
{
private readonly List<(LogLevel, string, Exception?)> _entries;
/// <summary>Initializes a new instance of the <c>CapturingLogger</c> class.</summary>
/// <param name="entries">The list that receives captured log entries.</param>
public CapturingLogger(List<(LogLevel, string, Exception?)> entries) => _entries = entries;
/// <summary>Returns no scope because the fake does not track scopes.</summary>
/// <typeparam name="TState">The type of the state object.</typeparam>
/// <param name="state">The scope or log state.</param>
/// <returns>A no-op scope.</returns>
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
/// <summary>Reports whether logging is enabled for the given level.</summary>
/// <param name="logLevel">The log level.</param>
/// <returns>True if the level is enabled; otherwise false.</returns>
public bool IsEnabled(LogLevel logLevel) => true;
/// <summary>Records the log entry for later assertions.</summary>
/// <typeparam name="TState">The type of the state object.</typeparam>
/// <param name="logLevel">The log level.</param>
/// <param name="eventId">The event id.</param>
/// <param name="state">The scope or log state.</param>
/// <param name="exception">The exception associated with the entry, if any.</param>
/// <param name="formatter">Formats the state and exception into the message text.</param>
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
Exception? exception, Func<TState, Exception?, string> formatter)
=> _entries.Add((logLevel, formatter(state, exception), exception));
}
}
/// <summary>Verifies that a genuine JavaScript exception while locking the diff dialog body is logged.</summary>
[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").SetVoidResult();
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);
});
}
/// <summary>Verifies that a circuit disconnect while locking the diff dialog body is not logged.</summary>
[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").SetVoidResult();
var cut = Render<DiffDialog>();
cut.InvokeAsync(() => cut.Instance.ShowAsync("Compare", "a", "b"));
cut.Render();
Assert.DoesNotContain(provider.Entries, e => e.Level >= LogLevel.Warning);
}
/// <summary>Verifies that a genuine JavaScript exception while copying parked messages is logged.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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;
/// <summary>Initializes a new instance of the <c>StubAuthStateProvider</c> class.</summary>
/// <param name="state">The authentication state the provider returns.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
}