From aa1c1093e4f3418979314e0d024d085baa1ec600 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 10 Jul 2026 03:48:49 -0400 Subject: [PATCH] fix(inbound-api): endpoint filter hot-reads MaxRequestBodyBytes via IOptionsMonitor Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj --- .../InboundApiEndpointFilter.cs | 10 +++--- .../EndpointGatingTests.cs | 35 ++++++++++++++++++- 2 files changed, 39 insertions(+), 6 deletions(-) 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; + } }