using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using ScadaLink.Commons.Entities.InboundApi; using ScadaLink.Commons.Interfaces.Services; namespace ScadaLink.InboundAPI.Tests; /// /// WP-3: Tests for script execution on central — timeout, handler dispatch, error handling. /// WP-5: Safe error messages. /// public class InboundScriptExecutorTests { private readonly InboundScriptExecutor _executor; private readonly RouteHelper _route; public InboundScriptExecutorTests() { _executor = new InboundScriptExecutor(NullLogger.Instance, Substitute.For()); var locator = Substitute.For(); var router = Substitute.For(); _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(), _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(), _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(), _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(), _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(), _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 { { "name", "ScadaLink" } }; var result = await _executor.ExecuteAsync( method, parameters, _route, TimeSpan.FromSeconds(10)); Assert.True(result.Success); Assert.Contains("ScadaLink", 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(), _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(), _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(), $"scadalink-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(), _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 { 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(), _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(), _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(), _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(counter), Substitute.For()); 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(), _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-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(), _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(), _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(), _route, TimeSpan.FromSeconds(10)); Assert.True(result.Success, result.ErrorMessage); Assert.Contains("whatever", result.ResultJson!); } private sealed class CompileLogCounter { public int CompilationFailures; } private sealed class CountingLogger(CompileLogCounter counter) : ILogger { public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => true; public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { var message = formatter(state, exception); if (message.Contains("script compilation failed", StringComparison.OrdinalIgnoreCase)) Interlocked.Increment(ref counter.CompilationFailures); } } }