diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundApiEndpointFilter.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundApiEndpointFilter.cs index cc56c0cc..a99af5c3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundApiEndpointFilter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundApiEndpointFilter.cs @@ -24,17 +24,17 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI; public sealed class InboundApiEndpointFilter : IEndpointFilter { private readonly ILogger _logger; - private readonly InboundApiOptions _options; + private readonly IOptionsMonitor _options; /// Initializes a new with the given logger and options. /// Logger for request rejection diagnostics. - /// Inbound API options including the maximum request body size. + /// Live-reloadable inbound API options, read per-request. public InboundApiEndpointFilter( ILogger logger, - IOptions options) + IOptionsMonitor options) { _logger = logger; - _options = options.Value; + _options = options; } /// Applies active-node gating and request body size checks before delegating to the next filter or handler. @@ -63,7 +63,7 @@ public sealed class InboundApiEndpointFilter : IEndpointFilter // Cap the request body size. Reject an over-limit body up // front via Content-Length; also lower the per-request max body size so a // chunked/unknown-length stream is cut off by Kestrel as it is read. - var maxBytes = _options.MaxRequestBodyBytes; + var maxBytes = _options.CurrentValue.MaxRequestBodyBytes; if (httpContext.Request.ContentLength is { } declaredLength && declaredLength > maxBytes) { _logger.LogWarning( diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointGatingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointGatingTests.cs index 5d3b0857..1eb62eab 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointGatingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointGatingTests.cs @@ -15,7 +15,7 @@ public class EndpointGatingTests { private static InboundApiEndpointFilter CreateFilter(InboundApiOptions? options = null) => new(NullLogger.Instance, - Options.Create(options ?? new InboundApiOptions())); + new TestOptionsMonitor(options ?? new InboundApiOptions())); private static (DefaultHttpContext ctx, DefaultEndpointFilterInvocationContext invocation) BuildInvocation(bool? activeNode, long? contentLength) @@ -144,4 +144,37 @@ public class EndpointGatingTests public bool IsReadOnly => false; public long? MaxRequestBodySize { get; set; } } + + // --- InboundAPI hot-reload: options must be re-read per request, not cached at ctor time --- + + [Fact] + public async Task BodySizeCap_HotReloads_WithoutRestart() + { + var monitor = new TestOptionsMonitor(new InboundApiOptions { MaxRequestBodyBytes = 10 }); + var filter = new InboundApiEndpointFilter(NullLogger.Instance, monitor); + + var (_, firstInvocation) = BuildInvocation(activeNode: true, contentLength: 20); + var firstNext = NextSentinel(out var firstHandlerRan); + var firstResult = await filter.InvokeAsync(firstInvocation, firstNext); + + Assert.False(firstHandlerRan()); + var firstStatus = Assert.IsAssignableFrom(firstResult); + Assert.Equal(StatusCodes.Status413PayloadTooLarge, firstStatus.StatusCode); + + monitor.CurrentValue = new InboundApiOptions { MaxRequestBodyBytes = 1024 * 1024 }; + + var (_, secondInvocation) = BuildInvocation(activeNode: true, contentLength: 20); + var secondNext = NextSentinel(out var secondHandlerRan); + await filter.InvokeAsync(secondInvocation, secondNext); + + Assert.True(secondHandlerRan()); + } + + private sealed class TestOptionsMonitor : IOptionsMonitor + { + public TestOptionsMonitor(T currentValue) => CurrentValue = currentValue; + public T CurrentValue { get; set; } + public T Get(string? name) => CurrentValue; + public IDisposable? OnChange(Action listener) => null; + } }