513fa1aa16
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.
229 lines
10 KiB
C#
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);
|
|
}
|
|
}
|