Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ScriptFormSandboxDisposalTests.cs
T
Joseph Doherty f1ef95f4da fix(centralui): P6-12 — dispose sandbox-run CTS in ApiMethodForm + SharedScriptForm
Neither Test Run form implemented IDisposable, so navigating away mid-run left
_runCts uncancelled: the sandbox kept the run alive until its own internal
script timeout, and the eventual continuation touched a torn-down component's
_running/_runResult fields. TemplateScriptDialog already got this right — both
forms now mirror it exactly: @implements IDisposable plus a Dispose() that
cancels + disposes _runCts.

ScriptAnalysisService.RunInSandboxAsync is marked virtual (test-only seam,
mirroring the existing virtual on DebugStreamService.StartStreamAsync/
StopStream) so a TaskCompletionSource-gated stub can hold a run open
deterministically. New bUnit tests (ScriptFormSandboxDisposalTests) prove, for
both forms: disposing mid-run cancels the token the sandbox observed, and the
cancelled continuation resolves with no unhandled exception and no _runResult/
_running mutation. Proof of failure: reverting only the two .razor changes
(keeping the virtual seam) reproduces all 4 failures — the token is never
cancelled and the no-op assertions time out.
2026-08-19 16:41:04 -04:00

287 lines
12 KiB
C#

using System.Reflection;
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ApiMethodFormPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.ApiMethodForm;
using SharedScriptFormPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.SharedScriptForm;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
/// <summary>
/// Regression tests for arch-review P6-12. <c>ApiMethodForm</c> and
/// <c>SharedScriptForm</c> run a Test Run sandbox script via
/// <c>AnalysisService.RunInSandboxAsync(request, _runCts.Token)</c> but — unlike
/// <c>TemplateScriptDialog</c>, which cancels/disposes <c>_runCts</c> from an
/// <see cref="IDisposable"/> <c>Dispose()</c> — neither form implemented
/// <see cref="IDisposable"/> at all. An abandoned run's <c>_runCts</c> was never
/// cancelled, so the sandbox kept the run alive until its own internal script
/// timeout; when it finally completed, the continuation touched a torn-down
/// component's <c>_running</c>/<c>_runResult</c> fields. The fix mirrors
/// <c>TemplateScriptDialog</c> exactly: <c>@implements IDisposable</c> plus a
/// <c>Dispose()</c> that cancels + disposes <c>_runCts</c>.
/// <para>
/// <see cref="GatedScriptAnalysisService"/> holds a run open on a
/// <see cref="TaskCompletionSource{T}"/> gate so the dispose-mid-run window is
/// deterministic (never <c>Task.Delay</c> ordering) — same pattern as
/// <c>DebugViewConnectDisposeRaceTests.GatedDebugStreamService</c>. Overriding
/// requires <c>ScriptAnalysisService.RunInSandboxAsync</c> to be <c>virtual</c> —
/// a test-only seam added alongside this fix (mirroring the pre-existing
/// <c>virtual</c> on <c>DebugStreamService.StartStreamAsync</c>/<c>StopStream</c>),
/// not itself part of the P6-12 defect or its fix.
/// </para>
/// <para>
/// <b>Proof of failure:</b> stashing only the two <c>.razor</c> changes (leaving
/// the <c>virtual</c> seam in place) and re-running
/// <see cref="ApiMethodFormSandboxDisposalTests.Dispose_MidRun_CancelsTheSandboxRunToken"/>
/// and
/// <see cref="SharedScriptFormSandboxDisposalTests.Dispose_MidRun_CancelsTheSandboxRunToken"/>
/// fails both: with no <see cref="IDisposable"/> on the form, <c>(cut.Instance as
/// IDisposable)</c> is <c>null</c>, so the disposal no-ops and the token the
/// sandbox observed is never cancelled.
/// </para>
/// </summary>
public class ApiMethodFormSandboxDisposalTests : BunitContext
{
private (IRenderedComponent<ApiMethodFormPage> Cut, GatedScriptAnalysisService Sandbox) RenderPage()
{
// ApiMethodForm mounts a MonacoEditor unconditionally; let bUnit answer its
// JS interop calls loosely (same convention as DebugView's disposal tests).
JSInterop.Mode = JSRuntimeMode.Loose;
var repo = Substitute.For<IInboundApiRepository>();
Services.AddSingleton(repo);
var keyAdmin = Substitute.For<IInboundApiKeyAdmin>();
keyAdmin.ListAsync(Arg.Any<CancellationToken>())
.Returns(Array.Empty<InboundApiKeyInfo>());
Services.AddSingleton(keyAdmin);
var sandbox = new GatedScriptAnalysisService();
Services.AddSingleton<ScriptAnalysisService>(sandbox);
var claims = new[]
{
new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Designer"),
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
return (Render<ApiMethodFormPage>(), sandbox);
}
/// <summary>
/// Starts <c>RunInSandboxAsync()</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
/// run window.
/// </summary>
private static async Task<Task> StartRunAsync(IRenderedComponent<ApiMethodFormPage> cut)
{
var run = typeof(ApiMethodFormPage).GetMethod(
"RunInSandboxAsync", BindingFlags.Instance | BindingFlags.NonPublic)!;
Task? task = null;
await cut.InvokeAsync(() => { task = (Task)run.Invoke(cut.Instance, null)!; });
return task!;
}
private static object? GetField(ApiMethodFormPage page, string name) =>
typeof(ApiMethodFormPage).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(page);
[Fact]
public async Task Dispose_MidRun_CancelsTheSandboxRunToken()
{
var (cut, sandbox) = RenderPage();
var run = await StartRunAsync(cut);
await sandbox.Entered.WaitAsync(TimeSpan.FromSeconds(5));
Assert.False(sandbox.LastToken.IsCancellationRequested,
"the run must still be in flight, unabandoned, before dispose");
// The user navigates away while the run is still in flight. Dispose runs off
// the renderer's thread, exactly as a circuit teardown can.
(cut.Instance as IDisposable)?.Dispose();
Assert.True(sandbox.LastToken.IsCancellationRequested,
"Dispose() must cancel _runCts so the sandbox stops the abandoned run " +
"instead of parking it until the script timeout");
Assert.Null(GetField(cut.Instance, "_runCts"));
// Unblock the gated run so nothing is left hanging past this test.
sandbox.ReleaseRun();
await run.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task Dispose_MidRun_ContinuationIsANoOp()
{
var (cut, sandbox) = RenderPage();
var run = await StartRunAsync(cut);
await sandbox.Entered.WaitAsync(TimeSpan.FromSeconds(5));
(cut.Instance as IDisposable)?.Dispose();
try
{
// The cancelled run's continuation must resolve on its own (no
// Task.Delay-based waiting) and must not surface an unhandled exception —
// RunInSandboxAsync's own catch(OperationCanceledException) swallows it.
var ex = await Record.ExceptionAsync(() => run.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.Null(ex);
// No state mutation: the disposed run must not have been (mis)recorded as
// an "Unexpected: ..." runtime error — it stays exactly as it started.
Assert.Null(GetField(cut.Instance, "_runResult"));
Assert.False((bool)GetField(cut.Instance, "_running")!);
}
finally
{
// Safety net so an unfixed run (never cancelled) doesn't hang past this test.
sandbox.ReleaseRun();
}
}
}
public class SharedScriptFormSandboxDisposalTests : BunitContext
{
private (IRenderedComponent<SharedScriptFormPage> Cut, GatedScriptAnalysisService Sandbox) RenderPage()
{
// SharedScriptForm mounts a MonacoEditor unconditionally; let bUnit answer its
// JS interop calls loosely (same convention as DebugView's disposal tests).
JSInterop.Mode = JSRuntimeMode.Loose;
var repo = Substitute.For<ITemplateEngineRepository>();
var audit = Substitute.For<IAuditService>();
Services.AddSingleton(repo);
Services.AddSingleton(audit);
Services.AddScoped<SharedScriptService>();
var sandbox = new GatedScriptAnalysisService();
Services.AddSingleton<ScriptAnalysisService>(sandbox);
var claims = new[]
{
new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Designer"),
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
return (Render<SharedScriptFormPage>(), sandbox);
}
private static async Task<Task> StartRunAsync(IRenderedComponent<SharedScriptFormPage> cut)
{
var run = typeof(SharedScriptFormPage).GetMethod(
"RunInSandboxAsync", BindingFlags.Instance | BindingFlags.NonPublic)!;
Task? task = null;
await cut.InvokeAsync(() => { task = (Task)run.Invoke(cut.Instance, null)!; });
return task!;
}
private static object? GetField(SharedScriptFormPage page, string name) =>
typeof(SharedScriptFormPage).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(page);
[Fact]
public async Task Dispose_MidRun_CancelsTheSandboxRunToken()
{
var (cut, sandbox) = RenderPage();
var run = await StartRunAsync(cut);
await sandbox.Entered.WaitAsync(TimeSpan.FromSeconds(5));
Assert.False(sandbox.LastToken.IsCancellationRequested,
"the run must still be in flight, unabandoned, before dispose");
(cut.Instance as IDisposable)?.Dispose();
Assert.True(sandbox.LastToken.IsCancellationRequested,
"Dispose() must cancel _runCts so the sandbox stops the abandoned run " +
"instead of parking it until the script timeout");
Assert.Null(GetField(cut.Instance, "_runCts"));
sandbox.ReleaseRun();
await run.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task Dispose_MidRun_ContinuationIsANoOp()
{
var (cut, sandbox) = RenderPage();
var run = await StartRunAsync(cut);
await sandbox.Entered.WaitAsync(TimeSpan.FromSeconds(5));
(cut.Instance as IDisposable)?.Dispose();
try
{
var ex = await Record.ExceptionAsync(() => run.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.Null(ex);
Assert.Null(GetField(cut.Instance, "_runResult"));
Assert.False((bool)GetField(cut.Instance, "_running")!);
}
finally
{
sandbox.ReleaseRun();
}
}
}
/// <summary>
/// A <see cref="ScriptAnalysisService"/> whose sandbox run can be held open on demand
/// via a <see cref="TaskCompletionSource{T}"/> gate, so the dispose-mid-run window is
/// deterministic across both script forms' tests. Overriding
/// <see cref="ScriptAnalysisService.RunInSandboxAsync"/> requires it to be
/// <c>virtual</c> — see the class-level remarks on
/// <see cref="ApiMethodFormSandboxDisposalTests"/>.
/// </summary>
internal sealed class GatedScriptAnalysisService : ScriptAnalysisService
{
private readonly TaskCompletionSource<SandboxRunResult> _gate =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _entered =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public GatedScriptAnalysisService()
: base(
Substitute.For<ISharedScriptCatalog>(),
new MemoryCache(new MemoryCacheOptions()),
new ServiceCollection().BuildServiceProvider())
{
}
/// <summary>Completes once <see cref="RunInSandboxAsync"/> has been entered.</summary>
public Task Entered => _entered.Task;
/// <summary>The cancellation token supplied to the most recent call.</summary>
public CancellationToken LastToken { get; private set; }
/// <summary>Completes the held-open run with a successful, inert result.</summary>
public void ReleaseRun() => _gate.TrySetResult(
new SandboxRunResult(true, "1", "int", "", null, SandboxErrorKind.None, 1, null));
public override async Task<SandboxRunResult> RunInSandboxAsync(SandboxRunRequest request, CancellationToken ct)
{
LastToken = ct;
_entered.TrySetResult();
using var reg = ct.Register(() => _gate.TrySetCanceled(ct));
return await _gate.Task;
}
}