691 lines
27 KiB
C#
691 lines
27 KiB
C#
using System.Data.Common;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NSubstitute;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
|
|
|
/// <summary>
|
|
/// WP-3: Tests for script execution on central — timeout, handler dispatch, error handling.
|
|
/// WP-5: Safe error messages.
|
|
/// </summary>
|
|
public class InboundScriptExecutorTests
|
|
{
|
|
private readonly InboundScriptExecutor _executor;
|
|
private readonly RouteHelper _route;
|
|
|
|
public InboundScriptExecutorTests()
|
|
{
|
|
_executor = new InboundScriptExecutor(NullLogger<InboundScriptExecutor>.Instance, Substitute.For<IServiceProvider>());
|
|
var locator = Substitute.For<IInstanceLocator>();
|
|
var router = Substitute.For<IInstanceRouter>();
|
|
_route = new RouteHelper(locator, router);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RegisteredHandler_ExecutesSuccessfully()
|
|
{
|
|
var method = new ApiMethod("test", "return 42;") { Id = 1, TimeoutSeconds = 10 };
|
|
_executor.RegisterHandler("test", async ctx =>
|
|
{
|
|
await Task.CompletedTask;
|
|
return new { result = 42 };
|
|
});
|
|
|
|
var result = await _executor.ExecuteAsync(
|
|
method,
|
|
new Dictionary<string, object?>(),
|
|
_route,
|
|
TimeSpan.FromSeconds(10));
|
|
|
|
Assert.True(result.Success);
|
|
Assert.NotNull(result.ResultJson);
|
|
Assert.Contains("42", result.ResultJson);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnregisteredHandler_InvalidScript_ReturnsCompilationFailure()
|
|
{
|
|
// Use an invalid script that cannot be compiled by Roslyn
|
|
var method = new ApiMethod("unknown", "%%% invalid C# %%%") { Id = 1, TimeoutSeconds = 10 };
|
|
|
|
var result = await _executor.ExecuteAsync(
|
|
method,
|
|
new Dictionary<string, object?>(),
|
|
_route,
|
|
TimeSpan.FromSeconds(10));
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("Script compilation failed", result.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnregisteredHandler_ValidScript_LazyCompiles()
|
|
{
|
|
// Valid script that is not pre-registered triggers lazy compilation
|
|
var method = new ApiMethod("lazy", "return 1;") { Id = 1, TimeoutSeconds = 10 };
|
|
|
|
var result = await _executor.ExecuteAsync(
|
|
method,
|
|
new Dictionary<string, object?>(),
|
|
_route,
|
|
TimeSpan.FromSeconds(10));
|
|
|
|
Assert.True(result.Success);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandlerThrows_ReturnsSafeErrorMessage()
|
|
{
|
|
var method = new ApiMethod("failing", "throw new Exception();") { Id = 1, TimeoutSeconds = 10 };
|
|
_executor.RegisterHandler("failing", _ => throw new InvalidOperationException("internal detail leak"));
|
|
|
|
var result = await _executor.ExecuteAsync(
|
|
method,
|
|
new Dictionary<string, object?>(),
|
|
_route,
|
|
TimeSpan.FromSeconds(10));
|
|
|
|
Assert.False(result.Success);
|
|
// WP-5: Safe error message — should NOT contain "internal detail leak"
|
|
Assert.Equal("Internal script error", result.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandlerTimesOut_ReturnsTimeoutError()
|
|
{
|
|
var method = new ApiMethod("slow", "Thread.Sleep(60000);") { Id = 1, TimeoutSeconds = 1 };
|
|
_executor.RegisterHandler("slow", async ctx =>
|
|
{
|
|
await Task.Delay(TimeSpan.FromSeconds(60), ctx.CancellationToken);
|
|
return "never";
|
|
});
|
|
|
|
var result = await _executor.ExecuteAsync(
|
|
method,
|
|
new Dictionary<string, object?>(),
|
|
_route,
|
|
TimeSpan.FromMilliseconds(100));
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("timed out", result.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandlerAccessesParameters()
|
|
{
|
|
var method = new ApiMethod("echo", "return params;") { Id = 1, TimeoutSeconds = 10 };
|
|
_executor.RegisterHandler("echo", async ctx =>
|
|
{
|
|
await Task.CompletedTask;
|
|
return ctx.Parameters["name"];
|
|
});
|
|
|
|
var parameters = new Dictionary<string, object?> { { "name", "ScadaBridge" } };
|
|
|
|
var result = await _executor.ExecuteAsync(
|
|
method, parameters, _route, TimeSpan.FromSeconds(10));
|
|
|
|
Assert.True(result.Success);
|
|
Assert.Contains("ScadaBridge", result.ResultJson!);
|
|
}
|
|
|
|
// --- InboundAPI-001: concurrent lazy-compile must not corrupt the handler cache ---
|
|
|
|
[Fact]
|
|
public async Task ConcurrentLazyCompile_SameMethod_DoesNotCorruptCache()
|
|
{
|
|
// Many concurrent first-callers of an uncompiled method race the lazy-compile
|
|
// path. With an unsynchronized Dictionary this can throw or return a torn/null
|
|
// handler; all calls must succeed and produce the same result.
|
|
var method = new ApiMethod("concurrent", "return 7;") { Id = 1, TimeoutSeconds = 10 };
|
|
|
|
var tasks = Enumerable.Range(0, 64).Select(_ => Task.Run(() =>
|
|
_executor.ExecuteAsync(
|
|
method,
|
|
new Dictionary<string, object?>(),
|
|
_route,
|
|
TimeSpan.FromSeconds(10)))).ToArray();
|
|
|
|
var results = await Task.WhenAll(tasks);
|
|
|
|
Assert.All(results, r =>
|
|
{
|
|
Assert.True(r.Success, r.ErrorMessage);
|
|
Assert.Equal("7", r.ResultJson);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConcurrentRegisterAndExecute_DoesNotThrow()
|
|
{
|
|
// RegisterHandler/RemoveHandler racing ExecuteAsync must not crash the process
|
|
// with an InvalidOperationException from concurrent Dictionary mutation.
|
|
var method = new ApiMethod("racy", "return 1;") { Id = 1, TimeoutSeconds = 10 };
|
|
|
|
var writers = Enumerable.Range(0, 32).Select(i => Task.Run(() =>
|
|
{
|
|
for (var n = 0; n < 50; n++)
|
|
{
|
|
_executor.RegisterHandler("racy", async ctx => { await Task.CompletedTask; return i; });
|
|
_executor.RemoveHandler("racy");
|
|
}
|
|
}));
|
|
|
|
var readers = Enumerable.Range(0, 32).Select(_ => Task.Run(async () =>
|
|
{
|
|
for (var n = 0; n < 50; n++)
|
|
{
|
|
await _executor.ExecuteAsync(
|
|
method, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
|
|
}
|
|
}));
|
|
|
|
// Should complete without an unhandled concurrency exception.
|
|
await Task.WhenAll(writers.Concat(readers));
|
|
}
|
|
|
|
// --- InboundAPI-005: compiled scripts must not bypass the script trust model ---
|
|
|
|
[Theory]
|
|
[InlineData("System.IO.File.Delete(\"/tmp/x\"); return null;")]
|
|
[InlineData("System.Diagnostics.Process.Start(\"/bin/sh\"); return null;")]
|
|
[InlineData("var t = System.Reflection.Assembly.GetExecutingAssembly(); return null;")]
|
|
[InlineData("new System.Threading.Thread(() => {}).Start(); return null;")]
|
|
[InlineData("var s = new System.Net.Sockets.Socket(System.Net.Sockets.AddressFamily.InterNetwork, System.Net.Sockets.SocketType.Stream, System.Net.Sockets.ProtocolType.Tcp); return null;")]
|
|
public void CompileAndRegister_ForbiddenApi_RejectsScript(string script)
|
|
{
|
|
var method = new ApiMethod("forbidden", script) { Id = 1, TimeoutSeconds = 10 };
|
|
|
|
var registered = _executor.CompileAndRegister(method);
|
|
|
|
Assert.False(registered);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExecuteAsync_ForbiddenApiScript_DoesNotRunAndReturnsFailure()
|
|
{
|
|
// A fully-qualified forbidden API call must be rejected at compile/register time
|
|
// so the script never executes.
|
|
var marker = System.IO.Path.Combine(
|
|
System.IO.Path.GetTempPath(), $"scadabridge-pwned-{Guid.NewGuid():N}");
|
|
System.IO.File.Delete(marker);
|
|
|
|
var method = new ApiMethod("evil", $"System.IO.File.WriteAllText(@\"{marker}\", \"x\"); return 1;")
|
|
{
|
|
Id = 1,
|
|
TimeoutSeconds = 10
|
|
};
|
|
|
|
var result = await _executor.ExecuteAsync(
|
|
method, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
|
|
|
|
Assert.False(result.Success);
|
|
Assert.False(System.IO.File.Exists(marker));
|
|
}
|
|
|
|
[Fact]
|
|
public void CompileAndRegister_PermittedScript_StillRegisters()
|
|
{
|
|
// The trust-model check must not reject legitimate scripts.
|
|
var method = new ApiMethod("ok", "var list = new List<int> { 1, 2, 3 }; return list.Sum();")
|
|
{
|
|
Id = 1,
|
|
TimeoutSeconds = 10
|
|
};
|
|
|
|
Assert.True(_executor.CompileAndRegister(method));
|
|
}
|
|
|
|
// --- InboundAPI-002: lazy compile-and-fetch must be atomic, never KeyNotFoundException ---
|
|
|
|
[Fact]
|
|
public async Task LazyCompile_RacingRemoveHandler_NeverThrowsKeyNotFound()
|
|
{
|
|
// The lazy-compile path must compile-and-fetch atomically: a concurrent
|
|
// RemoveHandler must not be able to turn a first-call into an "Internal
|
|
// script error" (the old check-then-act re-read could throw KeyNotFoundException).
|
|
var method = new ApiMethod("atomic", "return 5;") { Id = 1, TimeoutSeconds = 10 };
|
|
|
|
var removers = Enumerable.Range(0, 16).Select(_ => Task.Run(() =>
|
|
{
|
|
for (var n = 0; n < 200; n++)
|
|
_executor.RemoveHandler("atomic");
|
|
}));
|
|
|
|
var callers = Enumerable.Range(0, 16).Select(_ => Task.Run(async () =>
|
|
{
|
|
for (var n = 0; n < 50; n++)
|
|
{
|
|
var r = await _executor.ExecuteAsync(
|
|
method, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
|
|
// Result must always be a clean success or a clean compilation
|
|
// failure — never the catch-all "Internal script error".
|
|
Assert.NotEqual("Internal script error", r.ErrorMessage);
|
|
}
|
|
}));
|
|
|
|
await Task.WhenAll(removers.Concat(callers));
|
|
}
|
|
|
|
// --- InboundAPI-004: a client disconnect must NOT be reported as a script timeout ---
|
|
|
|
[Fact]
|
|
public async Task ClientDisconnect_IsNotReportedAsTimeout()
|
|
{
|
|
// When the caller's request token is cancelled (client aborted the request),
|
|
// ExecuteAsync must report a client-cancelled failure, not "Script execution
|
|
// timed out" — that log line is reserved for genuine timeouts.
|
|
var method = new ApiMethod("aborted", "return 1;") { Id = 1, TimeoutSeconds = 30 };
|
|
_executor.RegisterHandler("aborted", async ctx =>
|
|
{
|
|
await Task.Delay(TimeSpan.FromSeconds(60), ctx.CancellationToken);
|
|
return "never";
|
|
});
|
|
|
|
using var clientAborted = new CancellationTokenSource();
|
|
clientAborted.CancelAfter(TimeSpan.FromMilliseconds(100));
|
|
|
|
var result = await _executor.ExecuteAsync(
|
|
method,
|
|
new Dictionary<string, object?>(),
|
|
_route,
|
|
// Generous method timeout so the timeout CTS is NOT the cause.
|
|
TimeSpan.FromSeconds(30),
|
|
clientAborted.Token);
|
|
|
|
Assert.False(result.Success);
|
|
Assert.DoesNotContain("timed out", result.ErrorMessage ?? string.Empty);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GenuineTimeout_StillReportedAsTimeout()
|
|
{
|
|
// A method that exceeds its timeout with no client abort must still be
|
|
// reported as "timed out" (regression guard for the InboundAPI-004 fix).
|
|
var method = new ApiMethod("genuine", "return 1;") { Id = 1, TimeoutSeconds = 1 };
|
|
_executor.RegisterHandler("genuine", async ctx =>
|
|
{
|
|
await Task.Delay(TimeSpan.FromSeconds(60), ctx.CancellationToken);
|
|
return "never";
|
|
});
|
|
|
|
var result = await _executor.ExecuteAsync(
|
|
method,
|
|
new Dictionary<string, object?>(),
|
|
_route,
|
|
TimeSpan.FromMilliseconds(100),
|
|
CancellationToken.None);
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("timed out", result.ErrorMessage);
|
|
}
|
|
|
|
// --- InboundAPI-009: a script that fails to compile must be compiled at most
|
|
// once — repeated calls must not re-run the expensive Roslyn compilation. ---
|
|
|
|
[Fact]
|
|
public async Task FailedCompilation_IsNotRetriedOnEveryRequest()
|
|
{
|
|
// A broken script compiled once must be remembered as bad: subsequent
|
|
// ExecuteAsync calls must NOT recompile (CPU amplification vector — there is
|
|
// no rate limiting on the inbound API). Compilation is observed via the
|
|
// "compilation failed" log line, which must appear exactly once.
|
|
var counter = new CompileLogCounter();
|
|
var executor = new InboundScriptExecutor(
|
|
new CountingLogger<InboundScriptExecutor>(counter),
|
|
Substitute.For<IServiceProvider>());
|
|
|
|
var method = new ApiMethod("broken", "%%% invalid C# %%%") { Id = 1, TimeoutSeconds = 10 };
|
|
|
|
for (var i = 0; i < 5; i++)
|
|
{
|
|
var result = await executor.ExecuteAsync(
|
|
method, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
|
|
Assert.False(result.Success);
|
|
}
|
|
|
|
Assert.Equal(1, counter.CompilationFailures);
|
|
}
|
|
|
|
[Fact]
|
|
public void FailedCompilation_RecompilesAfterCompileAndRegisterCalledAgain()
|
|
{
|
|
// The failure cache must not be permanent: when the method definition is
|
|
// updated via CompileAndRegister, a now-valid script must register.
|
|
var bad = new ApiMethod("fixable", "%%% invalid %%%") { Id = 1, TimeoutSeconds = 10 };
|
|
Assert.False(_executor.CompileAndRegister(bad));
|
|
|
|
var good = new ApiMethod("fixable", "return 1;") { Id = 1, TimeoutSeconds = 10 };
|
|
Assert.True(_executor.CompileAndRegister(good));
|
|
}
|
|
|
|
// --- InboundAPI-024: _knownBadMethods must be bounded so a spam attack of
|
|
// unique method names cannot grow the cache without bound. ---
|
|
|
|
[Fact]
|
|
public void KnownBadMethodsCache_SizeNeverExceedsCap_UnderUniqueNameFlood()
|
|
{
|
|
// Flood the executor with bad-method names well past the cache cap. The
|
|
// cache must stabilise at or below the cap — any further unique bad name
|
|
// is dropped rather than added (the per-request DB lookup remains the
|
|
// correctness path; this cache is only a fast-fail optimisation).
|
|
const int cap = 1000;
|
|
const int floodCount = cap + 500;
|
|
|
|
for (var i = 0; i < floodCount; i++)
|
|
{
|
|
var bad = new ApiMethod($"bad-{i}", "%%% invalid %%%") { Id = i + 1, TimeoutSeconds = 10 };
|
|
Assert.False(_executor.CompileAndRegister(bad));
|
|
}
|
|
|
|
Assert.True(
|
|
_executor.KnownBadMethodCount <= cap,
|
|
$"known-bad cache size {_executor.KnownBadMethodCount} must not exceed cap {cap}");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task KnownBadMethodsCache_LazyCompilePath_AlsoCappedUnderUniqueNameFlood()
|
|
{
|
|
// The lazy-compile path (ExecuteAsync on an unregistered method) records
|
|
// failures via the same capped helper as CompileAndRegister, so flooding
|
|
// it with unique URLs must not grow the cache without bound.
|
|
const int cap = 1000;
|
|
const int floodCount = cap + 250;
|
|
|
|
for (var i = 0; i < floodCount; i++)
|
|
{
|
|
var method = new ApiMethod($"lazy-bad-{i}", "%%% invalid %%%") { Id = i + 1, TimeoutSeconds = 10 };
|
|
var result = await _executor.ExecuteAsync(
|
|
method, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
|
|
Assert.False(result.Success);
|
|
}
|
|
|
|
Assert.True(
|
|
_executor.KnownBadMethodCount <= cap,
|
|
$"known-bad cache size {_executor.KnownBadMethodCount} must not exceed cap {cap}");
|
|
}
|
|
|
|
// --- InboundAPI-014: the script return value is validated against ReturnDefinition ---
|
|
|
|
[Fact]
|
|
public async Task ReturnValue_MatchingReturnDefinition_Succeeds()
|
|
{
|
|
var method = new ApiMethod("shaped", "return x;")
|
|
{
|
|
Id = 1,
|
|
TimeoutSeconds = 10,
|
|
ReturnDefinition = """[{"name":"siteName","type":"String"},{"name":"total","type":"Integer"}]""",
|
|
};
|
|
_executor.RegisterHandler("shaped", async ctx =>
|
|
{
|
|
await Task.CompletedTask;
|
|
return new { siteName = "Site Alpha", total = 14250 };
|
|
});
|
|
|
|
var result = await _executor.ExecuteAsync(
|
|
method, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
|
|
|
|
Assert.True(result.Success, result.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReturnValue_NotMatchingReturnDefinition_ReturnsFailureNotMalformed200()
|
|
{
|
|
// The script returns a structure inconsistent with the declared return
|
|
// definition (missing 'total'). It must surface as a failure, not a 200.
|
|
var method = new ApiMethod("misshaped", "return x;")
|
|
{
|
|
Id = 1,
|
|
TimeoutSeconds = 10,
|
|
ReturnDefinition = """[{"name":"siteName","type":"String"},{"name":"total","type":"Integer"}]""",
|
|
};
|
|
_executor.RegisterHandler("misshaped", async ctx =>
|
|
{
|
|
await Task.CompletedTask;
|
|
return new { siteName = "Site Alpha" }; // 'total' missing
|
|
});
|
|
|
|
var result = await _executor.ExecuteAsync(
|
|
method, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Null(result.ResultJson);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReturnValue_NoReturnDefinition_IsUnconstrained()
|
|
{
|
|
// A method with no ReturnDefinition keeps the prior behaviour — the return
|
|
// value is serialized as-is.
|
|
var method = new ApiMethod("free", "return x;") { Id = 1, TimeoutSeconds = 10 };
|
|
_executor.RegisterHandler("free", async ctx =>
|
|
{
|
|
await Task.CompletedTask;
|
|
return new { whatever = 1 };
|
|
});
|
|
|
|
var result = await _executor.ExecuteAsync(
|
|
method, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
|
|
|
|
Assert.True(result.Success, result.ErrorMessage);
|
|
Assert.Contains("whatever", result.ResultJson!);
|
|
}
|
|
|
|
// --- Audit Log #23 (ParentExecutionId, T3): the inbound request's
|
|
// ExecutionId is threaded through ExecuteAsync onto routed calls ---
|
|
|
|
[Fact]
|
|
public async Task ExecuteAsync_WithParentExecutionId_RoutedCallCarriesItAsParentExecutionId()
|
|
{
|
|
// The endpoint hands ExecuteAsync the inbound request's ExecutionId; a
|
|
// routed Route.To(...).Call(...) inside the script must stamp that id onto
|
|
// the RouteToCallRequest as ParentExecutionId.
|
|
var inboundExecutionId = Guid.NewGuid();
|
|
|
|
var locator = Substitute.For<IInstanceLocator>();
|
|
locator.GetSiteIdForInstanceAsync("inst-1", Arg.Any<CancellationToken>()).Returns("SiteA");
|
|
var router = Substitute.For<IInstanceRouter>();
|
|
RouteToCallRequest? captured = null;
|
|
router.RouteToCallAsync("SiteA", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
|
|
.Returns(ci => new RouteToCallResponse(
|
|
((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));
|
|
var route = new RouteHelper(locator, router);
|
|
|
|
var method = new ApiMethod("routes", "return 1;") { Id = 1, TimeoutSeconds = 10 };
|
|
_executor.RegisterHandler("routes", async ctx =>
|
|
{
|
|
await ctx.Route.To("inst-1").Call("doWork");
|
|
return 1;
|
|
});
|
|
|
|
var result = await _executor.ExecuteAsync(
|
|
method, new Dictionary<string, object?>(), route, TimeSpan.FromSeconds(10),
|
|
parentExecutionId: inboundExecutionId);
|
|
|
|
Assert.True(result.Success, result.ErrorMessage);
|
|
Assert.NotNull(captured);
|
|
Assert.Equal(inboundExecutionId, captured!.ParentExecutionId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExecuteAsync_WithoutParentExecutionId_RoutedCallHasNullParentExecutionId()
|
|
{
|
|
// ExecuteAsync called without a parent execution id (the default) routes
|
|
// calls with ParentExecutionId null.
|
|
var locator = Substitute.For<IInstanceLocator>();
|
|
locator.GetSiteIdForInstanceAsync("inst-1", Arg.Any<CancellationToken>()).Returns("SiteA");
|
|
var router = Substitute.For<IInstanceRouter>();
|
|
RouteToCallRequest? captured = null;
|
|
router.RouteToCallAsync("SiteA", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
|
|
.Returns(ci => new RouteToCallResponse(
|
|
((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));
|
|
var route = new RouteHelper(locator, router);
|
|
|
|
var method = new ApiMethod("routes2", "return 1;") { Id = 1, TimeoutSeconds = 10 };
|
|
_executor.RegisterHandler("routes2", async ctx =>
|
|
{
|
|
await ctx.Route.To("inst-1").Call("doWork");
|
|
return 1;
|
|
});
|
|
|
|
var result = await _executor.ExecuteAsync(
|
|
method, new Dictionary<string, object?>(), route, TimeSpan.FromSeconds(10));
|
|
|
|
Assert.True(result.Success, result.ErrorMessage);
|
|
Assert.NotNull(captured);
|
|
Assert.Null(captured!.ParentExecutionId);
|
|
}
|
|
|
|
// --- IpsenMES MoveIn: the read-only Database helper is exposed on the script
|
|
// context and resolves IDatabaseGateway from a DI scope per execution. ---
|
|
|
|
[Fact]
|
|
public async Task Script_UsingDatabase_QueriesViaGateway()
|
|
{
|
|
// A script that calls Database.QuerySingle runs against an executor whose
|
|
// ServiceProvider registers an IDatabaseGateway backed by in-memory SQLite.
|
|
var services = new ServiceCollection();
|
|
services.AddSingleton<IDatabaseGateway>(SeededSqliteGateway());
|
|
using var provider = services.BuildServiceProvider();
|
|
|
|
var executor = new InboundScriptExecutor(
|
|
NullLogger<InboundScriptExecutor>.Instance, provider);
|
|
|
|
var method = new ApiMethod(
|
|
"movein",
|
|
"return new { v = Database.QuerySingle<string>(\"BTDB\", \"SELECT Code FROM Machine WHERE SAPID=@s\", new { s = (string)Parameters[\"sap\"] }) };")
|
|
{
|
|
Id = 1,
|
|
TimeoutSeconds = 10,
|
|
// null ReturnDefinition → ReturnValueValidator is unconstrained.
|
|
ReturnDefinition = null,
|
|
};
|
|
|
|
var result = await executor.ExecuteAsync(
|
|
method,
|
|
new Dictionary<string, object?> { { "sap", "131453" } },
|
|
_route,
|
|
TimeSpan.FromSeconds(10));
|
|
|
|
Assert.True(result.Success, result.ErrorMessage);
|
|
Assert.Contains("Z28061A", result.ResultJson!);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Script_NotUsingDatabase_RunsWithProviderLackingGateway()
|
|
{
|
|
// A script that never touches Database must run even when no IDatabaseGateway
|
|
// is registered — the helper is built via GetService (nullable) and only
|
|
// throws on first use, which this script never reaches.
|
|
var executor = new InboundScriptExecutor(
|
|
NullLogger<InboundScriptExecutor>.Instance, Substitute.For<IServiceProvider>());
|
|
|
|
var method = new ApiMethod("no-db", "return 99;") { Id = 1, TimeoutSeconds = 10 };
|
|
|
|
var result = await executor.ExecuteAsync(
|
|
method,
|
|
new Dictionary<string, object?>(),
|
|
_route,
|
|
TimeSpan.FromSeconds(10));
|
|
|
|
Assert.True(result.Success, result.ErrorMessage);
|
|
Assert.Contains("99", result.ResultJson!);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ScriptThrows_DisposesDiScope()
|
|
{
|
|
// When the script handler throws, ExecuteAsync must still dispose the
|
|
// per-execution DI scope it created (regression guard for the finally block).
|
|
var scopeServiceProvider = Substitute.For<IServiceProvider>();
|
|
// GetService<IDatabaseGateway>() returns null — script never needs it.
|
|
scopeServiceProvider.GetService(typeof(IDatabaseGateway)).Returns((object?)null);
|
|
|
|
var scope = Substitute.For<IServiceScope>();
|
|
scope.ServiceProvider.Returns(scopeServiceProvider);
|
|
|
|
var factory = Substitute.For<IServiceScopeFactory>();
|
|
factory.CreateScope().Returns(scope);
|
|
|
|
// Wire the factory into the provider so CreateScope() extension finds it.
|
|
var provider = Substitute.For<IServiceProvider>();
|
|
provider.GetService(typeof(IServiceScopeFactory)).Returns(factory);
|
|
|
|
var executor = new InboundScriptExecutor(
|
|
NullLogger<InboundScriptExecutor>.Instance, provider);
|
|
|
|
// A pre-registered handler that throws; ReturnDefinition=null so validation
|
|
// is not the failure path.
|
|
var method = new ApiMethod("throws", "throw new Exception(\"boom\");")
|
|
{
|
|
Id = 1,
|
|
TimeoutSeconds = 10,
|
|
ReturnDefinition = null,
|
|
};
|
|
executor.RegisterHandler("throws", _ => throw new Exception("boom"));
|
|
|
|
var result = await executor.ExecuteAsync(
|
|
method,
|
|
new Dictionary<string, object?>(),
|
|
_route,
|
|
TimeSpan.FromSeconds(10));
|
|
|
|
// The executor must swallow the script exception and return a non-success result.
|
|
Assert.False(result.Success);
|
|
|
|
// The scope must have been disposed via the finally block.
|
|
scope.Received(1).Dispose();
|
|
}
|
|
|
|
// SQLite-backed IDatabaseGateway fake (mirrors InboundDatabaseHelperTests). The
|
|
// shared in-memory db is seeded with Machine(Code,SAPID)=('Z28061A','131453').
|
|
private sealed class SqliteGateway : IDatabaseGateway
|
|
{
|
|
private readonly string _cs;
|
|
public SqliteGateway(string cs) => _cs = cs;
|
|
public async Task<DbConnection> GetConnectionAsync(string name, CancellationToken ct = default)
|
|
{ var c = new SqliteConnection(_cs); await c.OpenAsync(ct); return c; }
|
|
public Task<ExternalCallResult> CachedWriteAsync(string c, string s,
|
|
IReadOnlyDictionary<string, object?>? p = null, string? o = null, CancellationToken ct = default,
|
|
TrackedOperationId? t = null, Guid? e = null, string? src = null, Guid? pe = null)
|
|
=> throw new NotImplementedException();
|
|
}
|
|
|
|
private static SqliteGateway SeededSqliteGateway()
|
|
{
|
|
var keep = new SqliteConnection("DataSource=file:movein-exec?mode=memory&cache=shared");
|
|
keep.Open(); // keep-alive: shared in-memory db lives until process exit
|
|
using var cmd = keep.CreateCommand();
|
|
cmd.CommandText = "CREATE TABLE IF NOT EXISTS Machine(Code TEXT, SAPID TEXT); DELETE FROM Machine; INSERT INTO Machine VALUES('Z28061A','131453');";
|
|
cmd.ExecuteNonQuery();
|
|
return new SqliteGateway("DataSource=file:movein-exec?mode=memory&cache=shared");
|
|
}
|
|
|
|
private sealed class CompileLogCounter
|
|
{
|
|
public int CompilationFailures;
|
|
}
|
|
|
|
private sealed class CountingLogger<T>(CompileLogCounter counter) : ILogger<T>
|
|
{
|
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
|
|
public void Log<TState>(
|
|
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
|
Func<TState, Exception?, string> formatter)
|
|
{
|
|
var message = formatter(state, exception);
|
|
if (message.Contains("script compilation failed", StringComparison.OrdinalIgnoreCase))
|
|
Interlocked.Increment(ref counter.CompilationFailures);
|
|
}
|
|
}
|
|
}
|