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; /// /// Regression tests for arch-review P6-12. ApiMethodForm and /// SharedScriptForm run a Test Run sandbox script via /// AnalysisService.RunInSandboxAsync(request, _runCts.Token) but — unlike /// TemplateScriptDialog, which cancels/disposes _runCts from an /// Dispose() — neither form implemented /// at all. An abandoned run's _runCts 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 _running/_runResult fields. The fix mirrors /// TemplateScriptDialog exactly: @implements IDisposable plus a /// Dispose() that cancels + disposes _runCts. /// /// holds a run open on a /// gate so the dispose-mid-run window is /// deterministic (never Task.Delay ordering) — same pattern as /// DebugViewConnectDisposeRaceTests.GatedDebugStreamService. Overriding /// requires ScriptAnalysisService.RunInSandboxAsync to be virtual — /// a test-only seam added alongside this fix (mirroring the pre-existing /// virtual on DebugStreamService.StartStreamAsync/StopStream), /// not itself part of the P6-12 defect or its fix. /// /// /// Proof of failure: stashing only the two .razor changes (leaving /// the virtual seam in place) and re-running /// /// and /// /// fails both: with no on the form, (cut.Instance as /// IDisposable) is null, so the disposal no-ops and the token the /// sandbox observed is never cancelled. /// /// public class ApiMethodFormSandboxDisposalTests : BunitContext { private (IRenderedComponent 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(); Services.AddSingleton(repo); var keyAdmin = Substitute.For(); keyAdmin.ListAsync(Arg.Any()) .Returns(Array.Empty()); Services.AddSingleton(keyAdmin); var sandbox = new GatedScriptAnalysisService(); Services.AddSingleton(sandbox); var claims = new[] { new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(JwtTokenService.RoleClaimType, "Designer"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(user)); Services.AddAuthorizationCore(); return (Render(), sandbox); } /// /// Starts RunInSandboxAsync() 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. /// private static async Task StartRunAsync(IRenderedComponent 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 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(); var audit = Substitute.For(); Services.AddSingleton(repo); Services.AddSingleton(audit); Services.AddScoped(); var sandbox = new GatedScriptAnalysisService(); Services.AddSingleton(sandbox); var claims = new[] { new Claim(JwtTokenService.UsernameClaimType, "tester"), new Claim(JwtTokenService.RoleClaimType, "Designer"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(user)); Services.AddAuthorizationCore(); return (Render(), sandbox); } private static async Task StartRunAsync(IRenderedComponent 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(); } } } /// /// A whose sandbox run can be held open on demand /// via a gate, so the dispose-mid-run window is /// deterministic across both script forms' tests. Overriding /// requires it to be /// virtual — see the class-level remarks on /// . /// internal sealed class GatedScriptAnalysisService : ScriptAnalysisService { private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously); public GatedScriptAnalysisService() : base( Substitute.For(), new MemoryCache(new MemoryCacheOptions()), new ServiceCollection().BuildServiceProvider()) { } /// Completes once has been entered. public Task Entered => _entered.Task; /// The cancellation token supplied to the most recent call. public CancellationToken LastToken { get; private set; } /// Completes the held-open run with a successful, inert result. public void ReleaseRun() => _gate.TrySetResult( new SandboxRunResult(true, "1", "int", "", null, SandboxErrorKind.None, 1, null)); public override async Task RunInSandboxAsync(SandboxRunRequest request, CancellationToken ct) { LastToken = ct; _entered.TrySetResult(); using var reg = ct.Register(() => _gate.TrySetCanceled(ct)); return await _gate.Task; } }