fix(inbound-api): resolve InboundAPI-002,004,006,008 — disconnect vs timeout, body size limit, active-node gate; surface InboundAPI-007
This commit is contained in:
147
tests/ScadaLink.InboundAPI.Tests/EndpointGatingTests.cs
Normal file
147
tests/ScadaLink.InboundAPI.Tests/EndpointGatingTests.cs
Normal file
@@ -0,0 +1,147 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ScadaLink.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// InboundAPI-006 / InboundAPI-008: the POST /api/{methodName} endpoint must be
|
||||
/// gated to the active central node and must cap the request body size. These
|
||||
/// behaviours are enforced by <see cref="InboundApiEndpointFilter"/>.
|
||||
/// </summary>
|
||||
public class EndpointGatingTests
|
||||
{
|
||||
private static InboundApiEndpointFilter CreateFilter(InboundApiOptions? options = null) =>
|
||||
new(NullLogger<InboundApiEndpointFilter>.Instance,
|
||||
Options.Create(options ?? new InboundApiOptions()));
|
||||
|
||||
private static (DefaultHttpContext ctx, DefaultEndpointFilterInvocationContext invocation)
|
||||
BuildInvocation(bool? activeNode, long? contentLength)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
if (activeNode.HasValue)
|
||||
services.AddSingleton<IActiveNodeGate>(new StubActiveNodeGate(activeNode.Value));
|
||||
|
||||
var ctx = new DefaultHttpContext
|
||||
{
|
||||
RequestServices = services.BuildServiceProvider()
|
||||
};
|
||||
ctx.Request.Method = "POST";
|
||||
if (contentLength.HasValue)
|
||||
ctx.Request.ContentLength = contentLength.Value;
|
||||
|
||||
return (ctx, new DefaultEndpointFilterInvocationContext(ctx));
|
||||
}
|
||||
|
||||
private static EndpointFilterDelegate NextSentinel(out Func<bool> wasCalled)
|
||||
{
|
||||
var called = false;
|
||||
wasCalled = () => called;
|
||||
return _ =>
|
||||
{
|
||||
called = true;
|
||||
return ValueTask.FromResult<object?>(Results.Ok("handler-ran"));
|
||||
};
|
||||
}
|
||||
|
||||
// --- InboundAPI-008: standby node must not serve inbound API calls ---
|
||||
|
||||
[Fact]
|
||||
public async Task StandbyNode_ShortCircuitsWith503_AndDoesNotRunHandler()
|
||||
{
|
||||
var (_, invocation) = BuildInvocation(activeNode: false, contentLength: 2);
|
||||
var next = NextSentinel(out var handlerRan);
|
||||
|
||||
var result = await CreateFilter().InvokeAsync(invocation, next);
|
||||
|
||||
Assert.False(handlerRan());
|
||||
var status = Assert.IsAssignableFrom<IStatusCodeHttpResult>(result);
|
||||
Assert.Equal(StatusCodes.Status503ServiceUnavailable, status.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActiveNode_PassesGate_RunsHandler()
|
||||
{
|
||||
var (_, invocation) = BuildInvocation(activeNode: true, contentLength: 2);
|
||||
var next = NextSentinel(out var handlerRan);
|
||||
|
||||
await CreateFilter().InvokeAsync(invocation, next);
|
||||
|
||||
Assert.True(handlerRan());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoGateRegistered_PassesGate_RunsHandler()
|
||||
{
|
||||
// When no IActiveNodeGate is registered (non-clustered host), gating is
|
||||
// opt-in and defaults to "allow" so the endpoint is still served.
|
||||
var (_, invocation) = BuildInvocation(activeNode: null, contentLength: 2);
|
||||
var next = NextSentinel(out var handlerRan);
|
||||
|
||||
await CreateFilter().InvokeAsync(invocation, next);
|
||||
|
||||
Assert.True(handlerRan());
|
||||
}
|
||||
|
||||
// --- InboundAPI-006: request body size must be capped ---
|
||||
|
||||
[Fact]
|
||||
public async Task OversizedBody_ShortCircuitsWith413_AndDoesNotRunHandler()
|
||||
{
|
||||
var options = new InboundApiOptions();
|
||||
var (_, invocation) = BuildInvocation(
|
||||
activeNode: true, contentLength: options.MaxRequestBodyBytes + 1);
|
||||
var next = NextSentinel(out var handlerRan);
|
||||
|
||||
var result = await CreateFilter(options).InvokeAsync(invocation, next);
|
||||
|
||||
Assert.False(handlerRan());
|
||||
var status = Assert.IsAssignableFrom<IStatusCodeHttpResult>(result);
|
||||
Assert.Equal(StatusCodes.Status413PayloadTooLarge, status.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BodyAtLimit_RunsHandler()
|
||||
{
|
||||
var options = new InboundApiOptions();
|
||||
var (_, invocation) = BuildInvocation(
|
||||
activeNode: true, contentLength: options.MaxRequestBodyBytes);
|
||||
var next = NextSentinel(out var handlerRan);
|
||||
|
||||
await CreateFilter(options).InvokeAsync(invocation, next);
|
||||
|
||||
Assert.True(handlerRan());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FilterCapsMaxRequestBodySizeFeature()
|
||||
{
|
||||
// For chunked/unknown-length requests there is no Content-Length, so the
|
||||
// filter must also cap the per-request body size feature so Kestrel rejects
|
||||
// an oversized stream while it is being read.
|
||||
var options = new InboundApiOptions();
|
||||
var (ctx, invocation) = BuildInvocation(activeNode: true, contentLength: null);
|
||||
ctx.Features.Set<IHttpMaxRequestBodySizeFeature>(new StubMaxBodySizeFeature());
|
||||
var next = NextSentinel(out _);
|
||||
|
||||
await CreateFilter(options).InvokeAsync(invocation, next);
|
||||
|
||||
var feature = ctx.Features.Get<IHttpMaxRequestBodySizeFeature>();
|
||||
Assert.Equal(options.MaxRequestBodyBytes, feature!.MaxRequestBodySize);
|
||||
}
|
||||
|
||||
private sealed class StubActiveNodeGate : IActiveNodeGate
|
||||
{
|
||||
private readonly bool _isActive;
|
||||
public StubActiveNodeGate(bool isActive) => _isActive = isActive;
|
||||
public bool IsActiveNode => _isActive;
|
||||
}
|
||||
|
||||
private sealed class StubMaxBodySizeFeature : IHttpMaxRequestBodySizeFeature
|
||||
{
|
||||
public bool IsReadOnly => false;
|
||||
public long? MaxRequestBodySize { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -239,4 +239,88 @@ public class InboundScriptExecutorTests
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
|
||||
Reference in New Issue
Block a user