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; /// /// 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 . /// public class EndpointGatingTests { private static InboundApiEndpointFilter CreateFilter(InboundApiOptions? options = null) => new(NullLogger.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(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 wasCalled) { var called = false; wasCalled = () => called; return _ => { called = true; return ValueTask.FromResult(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(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(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(new StubMaxBodySizeFeature()); var next = NextSentinel(out _); await CreateFilter(options).InvokeAsync(invocation, next); var feature = ctx.Features.Get(); 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; } } }