using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using ScadaLink.Commons.Entities.InboundApi; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Communication; 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 commService = Substitute.For( Microsoft.Extensions.Options.Options.Create(new CommunicationOptions()), NullLogger.Instance); _route = new RouteHelper(locator, commService); } [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)); } }