289 lines
12 KiB
C#
289 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();
|
|
Services.AddTestConfigurationMutations();
|
|
|
|
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();
|
|
Services.AddTestConfigurationMutations();
|
|
|
|
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;
|
|
}
|
|
}
|