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:
@@ -56,15 +56,45 @@ public class ApiKeyValidatorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidKey_MethodNotFound_Returns400()
|
||||
public async Task ValidKey_MethodNotFound_IsIndistinguishableFromNotApproved()
|
||||
{
|
||||
// InboundAPI-011: a "method not found" response must not be observably
|
||||
// different from a "key not approved" response, or a caller holding any
|
||||
// valid key could enumerate which method names exist on the central API.
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
var method = new ApiMethod("realMethod", "return 1;") { Id = 10 };
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
_repository.GetMethodByNameAsync("nonExistent").Returns((ApiMethod?)null);
|
||||
_repository.GetMethodByNameAsync("realMethod").Returns(method);
|
||||
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey>());
|
||||
|
||||
var notFound = await _validator.ValidateAsync("valid-key", "nonExistent");
|
||||
var notApproved = await _validator.ValidateAsync("valid-key", "realMethod");
|
||||
|
||||
Assert.False(notFound.IsValid);
|
||||
Assert.False(notApproved.IsValid);
|
||||
// Status code and error message must be identical so existence is not observable.
|
||||
Assert.Equal(notApproved.StatusCode, notFound.StatusCode);
|
||||
Assert.Equal(notApproved.ErrorMessage, notFound.ErrorMessage);
|
||||
Assert.Equal(403, notFound.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidKey_MethodNotFound_ErrorMessageDoesNotEchoMethodName()
|
||||
{
|
||||
// InboundAPI-011: the error body must not echo the caller-supplied method
|
||||
// name back verbatim (reflected-input) and must not confirm non-existence.
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
_repository.GetMethodByNameAsync("probe-XYZ").Returns((ApiMethod?)null);
|
||||
|
||||
var result = await _validator.ValidateAsync("valid-key", "probe-XYZ");
|
||||
|
||||
var result = await _validator.ValidateAsync("valid-key", "nonExistent");
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(400, result.StatusCode);
|
||||
Assert.DoesNotContain("probe-XYZ", result.ErrorMessage ?? string.Empty);
|
||||
Assert.DoesNotContain("not found", result.ErrorMessage ?? string.Empty,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,4 +134,40 @@ public class ParameterValidatorTests
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("Unknown parameter type", result.ErrorMessage);
|
||||
}
|
||||
|
||||
// --- InboundAPI-010: unexpected top-level body fields must be reported so
|
||||
// callers get feedback on typo'd parameter names instead of silent ignore. ---
|
||||
|
||||
[Fact]
|
||||
public void UnexpectedBodyField_ReturnsInvalid()
|
||||
{
|
||||
var definitions = JsonSerializer.Serialize(new[]
|
||||
{
|
||||
new { Name = "value", Type = "Integer", Required = true }
|
||||
});
|
||||
|
||||
// "valeu" is a typo for "value"; the caller must be told, not ignored.
|
||||
using var doc = JsonDocument.Parse("{\"value\": 1, \"valeu\": 2}");
|
||||
var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("valeu", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnlyDefinedFields_StillValid()
|
||||
{
|
||||
// Regression guard: a body containing exactly the defined parameters
|
||||
// must continue to validate.
|
||||
var definitions = JsonSerializer.Serialize(new[]
|
||||
{
|
||||
new { Name = "value", Type = "Integer", Required = true }
|
||||
});
|
||||
|
||||
using var doc = JsonDocument.Parse("{\"value\": 1}");
|
||||
var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal((long)1, result.Parameters["value"]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user