Files
scadalink-design/tests/ScadaLink.InboundAPI.Tests/InboundScriptExecutorTests.cs

450 lines
17 KiB
C#

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;
/// <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", "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<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(), $"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<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-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!);
}
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);
}
}
}