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;
}
}