Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewConnectDisposeRaceTests.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

229 lines
10 KiB
C#

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 ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.DebugView;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Regression tests for a dispose race in the debug view. <c>Connect()</c> awaits
/// <c>StartStreamAsync</c> — which can take up to 30s — and then publishes the result into
/// <c>_session</c>. If the circuit was torn down inside that window, <c>Dispose()</c> had
/// already run, found <c>_session</c> still null and stopped nothing, so the just-created
/// session stayed registered in the singleton <see cref="DebugStreamService"/>. Its
/// keepalive renews registered sessions forever (the orphan reaper only collects
/// UNregistered ones), so the site subscription, the bridge actor and the dead circuit's
/// object graph were pinned until process restart. The fix publishes the session and
/// re-checks disposal under one lock, so exactly one side tears the session down.
/// </summary>
public class DebugViewConnectDisposeRaceTests : BunitContext
{
/// <summary>
/// A <see cref="DebugStreamService"/> whose start can be held open on demand, so the
/// dispose-during-start window is deterministic rather than timing-dependent.
/// </summary>
private sealed class GatedDebugStreamService : DebugStreamService
{
private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly List<string> _stopped = new();
/// <summary>Creates a debug stream service whose stream start is held until released.</summary>
/// <param name="comms">The communication service.</param>
/// <param name="provider">The service provider.</param>
/// <param name="factory">The gRPC client factory.</param>
public GatedDebugStreamService(
CommunicationService comms, IServiceProvider provider, SiteStreamGrpcClientFactory factory)
: base(comms, provider, factory, NullLogger<DebugStreamService>.Instance)
{
}
/// <summary>The id of the session this service hands out.</summary>
public string SessionId => "session-under-test";
/// <summary>Completes once <see cref="StartStreamAsync"/> has been entered.</summary>
public Task Entered => _entered.Task;
/// <summary>Session ids passed to <see cref="StopStream"/>, in call order.</summary>
public IReadOnlyList<string> Stopped
{
get { lock (_stopped) { return _stopped.ToList(); } }
}
/// <summary>Releases the held stream start so it can complete.</summary>
public void ReleaseStart() => _gate.TrySetResult();
/// <inheritdoc />
public override async Task<DebugStreamSession> StartStreamAsync(
int instanceId, Action<object> onEvent, Action onTerminated, CancellationToken ct = default)
{
_entered.TrySetResult();
await _gate.Task;
return new DebugStreamSession(
SessionId,
new DebugViewSnapshot(
"Inst-1",
Array.Empty<AttributeValueChanged>(),
Array.Empty<AlarmStateChanged>(),
DateTimeOffset.UtcNow));
}
/// <inheritdoc />
public override void StopStream(string sessionId)
{
lock (_stopped) { _stopped.Add(sessionId); }
}
}
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
/// <summary>Creates a provider that always returns the given authentication state.</summary>
/// <param name="state">The authentication state to return.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
private (IRenderedComponent<DebugViewPage> Cut, GatedDebugStreamService Stream) 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);
// An empty throwaway provider — the gated stub never calls into the base start
// path, so nothing is resolved from it.
var stream = new GatedDebugStreamService(
comms,
new ServiceCollection().BuildServiceProvider(),
new SiteStreamGrpcClientFactory(NullLoggerFactory.Instance));
Services.AddSingleton<DebugStreamService>(stream);
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>(), stream);
}
private static void SetField(DebugViewPage page, string name, object value) =>
typeof(DebugViewPage).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!
.SetValue(page, value);
private static object? GetField(DebugViewPage page, string name) =>
typeof(DebugViewPage).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(page);
/// <summary>
/// Starts <c>Connect()</c> on the renderer's dispatcher (where it really runs) and hands
/// back its task without awaiting it, so the test can act inside the start window.
/// </summary>
private static async Task<Task> StartConnectAsync(IRenderedComponent<DebugViewPage> cut)
{
var connect = typeof(DebugViewPage).GetMethod(
"Connect", BindingFlags.Instance | BindingFlags.NonPublic)!;
Task? task = null;
await cut.InvokeAsync(() => { task = (Task)connect.Invoke(cut.Instance, null)!; });
return task!;
}
/// <summary>Verifies that disposing during a pending connect stops the just-created session.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Connect_DisposedMidStart_StopsTheJustCreatedSession()
{
var (cut, stream) = RenderPage();
SetField(cut.Instance, "_selectedSiteId", 1);
SetField(cut.Instance, "_selectedInstanceId", 7);
var connect = await StartConnectAsync(cut);
await stream.Entered.WaitAsync(TimeSpan.FromSeconds(5));
// The user navigates away while the start is still in flight. Dispose runs off the
// renderer's thread, exactly as a circuit teardown can.
cut.Instance.Dispose();
stream.ReleaseStart();
await connect.WaitAsync(TimeSpan.FromSeconds(10));
Assert.Equal(new[] { stream.SessionId }, stream.Stopped);
Assert.Null(GetField(cut.Instance, "_session"));
}
/// <summary>Verifies that connecting and then disposing stops the session exactly once.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Connect_ThenDispose_StopsTheSessionExactlyOnce()
{
var (cut, stream) = RenderPage();
SetField(cut.Instance, "_selectedSiteId", 1);
SetField(cut.Instance, "_selectedInstanceId", 7);
// Normal path: the start completes while the component is very much alive.
stream.ReleaseStart();
var connect = await StartConnectAsync(cut);
await connect.WaitAsync(TimeSpan.FromSeconds(10));
Assert.Empty(stream.Stopped);
Assert.NotNull(GetField(cut.Instance, "_session"));
cut.Instance.Dispose();
Assert.Equal(new[] { stream.SessionId }, stream.Stopped);
// A second Dispose (bUnit tears the component down again at test end) must not
// stop an already-released session a second time.
cut.Instance.Dispose();
Assert.Equal(new[] { stream.SessionId }, stream.Stopped);
}
/// <summary>Verifies that disconnecting after a connect stops the session once and clears the session slot.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Disconnect_AfterConnect_StopsTheSessionOnceAndClearsTheSlot()
{
var (cut, stream) = RenderPage();
SetField(cut.Instance, "_selectedSiteId", 1);
SetField(cut.Instance, "_selectedInstanceId", 7);
stream.ReleaseStart();
var connect = await StartConnectAsync(cut);
await connect.WaitAsync(TimeSpan.FromSeconds(10));
var disconnect = typeof(DebugViewPage).GetMethod(
"Disconnect", BindingFlags.Instance | BindingFlags.NonPublic)!;
await cut.InvokeAsync(() => (Task)disconnect.Invoke(cut.Instance, null)!);
Assert.Equal(new[] { stream.SessionId }, stream.Stopped);
Assert.Null(GetField(cut.Instance, "_session"));
// Dispose after an explicit disconnect has nothing left to stop.
cut.Instance.Dispose();
Assert.Equal(new[] { stream.SessionId }, stream.Stopped);
}
}