fix(inbound-api): resolve InboundAPI-009,010,011,013 — cache failed compiles, reject unknown body fields, close enumeration oracle, drop misnamed factory; InboundAPI-007,012 flagged

This commit is contained in:
Joseph Doherty
2026-05-16 22:24:03 -04:00
parent 8664cdf940
commit 858fe24add
7 changed files with 255 additions and 19 deletions

View File

@@ -1,3 +1,4 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ScadaLink.Commons.Entities.InboundApi;
@@ -323,4 +324,63 @@ public class InboundScriptExecutorTests
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));
}
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);
}
}
}