Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,9 @@ node_modules/
*.key
*.p12
*.pfx

# Generated test-run reports. `dotnet test --logger trx` writes these under a
# TestResults/ folder next to the project; nothing in the repo consumes them,
# and they embed the developer's machine name and absolute home path.
TestResults/
*.trx
13 changes: 8 additions & 5 deletions specs/product/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,11 +261,14 @@ exist in the status enum for later phases; nothing sets them yet, and only
**Sales line (#99)** — sells a **product** in a packed unit. At line creation
the line snapshots the product type, the grade the product mapped to, and the
unit's eggs-per-unit factor (`base_unit_factor`, spec §10.5/§9.7); `quantity`
is selling units, `quantity_base = quantity × factor` is individual eggs —
allocation and the stock guard run on `quantity_base`. Re-pointing a
product's grade or redefining a packed unit only affects future lines, never
recorded ones. Price is per selling unit, prefilled from the product's
default and editable per line.
is selling units and always a whole number — the add/edit-line controls
reject a fractional value before sending, and a direct API request gets a
stable validation response rather than a raw JSON-binding error (#398) —
while `quantity_base = quantity × factor` is individual eggs; allocation and
the stock guard run on `quantity_base`. Re-pointing a product's grade or
redefining a packed unit only affects future lines, never recorded ones.
Price is per selling unit (decimal money, stored as integer minor units),
prefilled from the product's default and editable per line.

**Void** — undo of a mistaken confirm (requires a reason): the allocated
quantities return to the *exact* egg lots they were drawn from (recorded at
Expand Down
5 changes: 5 additions & 0 deletions src/Cluckwork.Api/Endpoints/Accounts/FarmLogoEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ namespace Cluckwork.Api.Endpoints.Accounts;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
using Cluckwork.Api.Hosting;

// #123 — the farm logo: upload, serve, remove.
//
Expand All @@ -32,6 +33,10 @@ public static RouteGroupBuilder MapFarmLogoEndpoints(this RouteGroupBuilder grou
.WithSummary("The farm logo image. 404 when none is set; the chrome falls back to app branding.");

group.MapPut("/logo", SetLogo)
// #398 — reads the raw body directly, so it declares no typed body
// parameter and carries no IAcceptsMetadata. Without this marker a
// 400 body failure here would be reported as a query error.
.WithMetadata(new ReadsRequestBodyAttribute())
.RequireAuthorization(AuthPolicies.AdminOnly)
.WithName("SetFarmLogo")
.WithSummary(
Expand Down
7 changes: 7 additions & 0 deletions src/Cluckwork.Api/Endpoints/Auth/AuthEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ public static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder group)
// #309 — refresh carries no body of its own (the token rides in the
// cookie); 1 KB is a defensive cap so a junk body can't be streamed in.
.WithMaxRequestBodyBytes(1024)
// #398 review round 8 — the handler DRAINS Request.Body to enforce
// that cap (see Refresh), so Kestrel can raise a 400 here for a
// truncated/malformed body. It declares no typed body parameter, so
// without this marker that failure would be reported as `errors.query`
// on an endpoint with no query input at all. BodyReadingEndpointTests
// is what keeps this from being forgotten again.
.WithMetadata(new ReadsRequestBodyAttribute())
.WithName("RefreshToken")
.WithSummary("Rotate the refresh-token cookie and return a fresh access token.");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ namespace Cluckwork.Api.Endpoints.ClientErrors;
using System.Text.Json;
using Cluckwork.Api.RateLimiting;
using Microsoft.AspNetCore.Http.Features;
using Cluckwork.Api.Hosting;

// #217 — the SPA's ErrorBoundary reports render crashes here so the operator
// learns a screen is crashing without a support screenshot. The report is
Expand Down Expand Up @@ -30,6 +31,9 @@ public static class ClientErrorEndpoints
public static RouteGroupBuilder MapClientErrorEndpoints(this RouteGroupBuilder group)
{
group.MapPost("/", Report)
// #398 — binds HttpRequest and reads the body itself, so it carries
// no IAcceptsMetadata; see ReadsRequestBodyAttribute.
.WithMetadata(new ReadsRequestBodyAttribute())
.AllowAnonymous()
.RequireRateLimiting(RateLimitingOptions.ClientErrorsPolicyName)
.WithName("ReportClientError")
Expand Down
143 changes: 143 additions & 0 deletions src/Cluckwork.Api/Hosting/BindingFailureResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
namespace Cluckwork.Api.Hosting;

using Cluckwork.Api.Validation;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http.Metadata;

// #398 review (Codex) — RouteHandlerOptions.ThrowOnBadRequest is forced true
// in every environment (see Program.cs), so a JSON-binding failure (a
// fractional quantity into an int, an unparseable date/guid, malformed JSON
// syntax, …) throws a BadHttpRequestException deep inside minimal-API's
// generated body-reader instead of the framework silently setting a 400 and
// returning. Before this middleware existed, that exception propagated all
// the way up through UseSerilogRequestLogging (registered ABOVE this
// middleware in Program.cs's pipeline) before UseExceptionHandler (registered
// above THAT) finally caught it and mapped it to the correct 400
// ValidationProblem at /error. Serilog.AspNetCore's RequestLoggingMiddleware
// catches ANY exception that passes through it and logs that request's
// completion with a HARDCODED StatusCode 500 at Error — so an ordinary
// malformed body (a fractional quantity, a bad date, scanner traffic) was
// logged as a 500 server fault while the client correctly received a 400.
// That inflated 5xx/error telemetry and would trigger false alerts;
// RequestLoggingTests pins the fix.
//
// Registered in Program.cs IMMEDIATELY AFTER UseSerilogRequestLogging — i.e.
// INSIDE it in the pipeline — so THIS middleware is the first thing to see
// the exception. It catches it, writes the real 400 body itself, and
// (critically) does NOT rethrow: control returns to Serilog with a normal
// 400 response and no exception in flight, so GetLevel's ordinary
// "exception is not null || StatusCode >= 500" check sees neither and logs
// Information — exactly like any other well-formed 400. No re-execution at
// /error happens either, since UseExceptionHandler (which sits ABOVE Serilog)
// never sees anything propagate that far.

// #398 review round 7 (Codex) — marks an endpoint that reads the request body
// MANUALLY (raw stream, or a bound `HttpRequest`) instead of declaring a typed
// body parameter. Those carry no IAcceptsMetadata, so without this marker
// ConcernsRequestBody would classify a body failure on them as a query one —
// and they have no query input at all.
//
// Deliberately a private marker rather than `.Accepts<T>(contentType)`:
// Accepts also imposes a CONTENT-TYPE CONSTRAINT ON THE ROUTE, so adding it
// would change which requests match and could start 404/415-ing callers that
// work today. This affects only how a failure is described.
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public sealed class ReadsRequestBodyAttribute : Attribute;

public static class BindingFailureResponse
{
public static IApplicationBuilder UseCluckworkBindingFailureResponse(this IApplicationBuilder app) =>
app.Use(static async (context, next) =>
{
try
{
await next();
}
catch (BadHttpRequestException ex) when (ex.StatusCode < StatusCodes.Status500InternalServerError)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Distinguish query failures from body failures

With ThrowOnBadRequest enabled globally, failed binding of typed query parameters also throws a 400 BadHttpRequestException; for example, an authenticated GET /api/v1/reports/production?from=not-a-date or a list request with ?limit=abc reaches this catch. Because every such exception is rendered through BindingFailureProblem(), these bodyless requests incorrectly report that the request body is invalid under the body error key. Restrict this response to JSON-body binding failures or produce a source-appropriate problem for query binding failures.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 94f5506. This one is real, and I reproduced it before touching anything: GET /api/v1/reports/production?from=not-a-date returned

{"...","status":400,"errors":{"body":["The request body has an invalid or incorrectly formatted value."]}}

on a request that has no body. Exactly as you described — ThrowOnBadRequest is global, so a failed typed query parameter throws the same 400 BadHttpRequestException and lands in the same catch, and rendering all of them through one body-shaped problem told the caller something plainly false.

Fix keyed off the request, not the message. hasBody comes from ContentLength/Transfer-Encoding, deliberately not from ex.Message. The binding messages are framework internals that differ per binding source and can change between versions, so matching on them would pass today and silently mis-classify on the next upgrade — the same class of trap as the premise that broke on the 413 path.

Scope stated honestly: a request that does carry a body still reports body, so a bad query parameter on a POST would still land there. That case is genuinely ambiguous from inside the middleware and body remains the likelier culprit; what this fixes is the case that was certainly wrong. Both the middleware and the /error backstop derive hasBody the same way, so they cannot disagree.

Mutation-checked: forcing hasBody = true turns the new Query_binding_failure_does_not_blame_the_request_body RED while Binding_failure_logs_one_completion_at_information_with_status_400 stays green — so the two are orthogonal and neither is covering for the other. 33/33 across RequestLoggingTests, AuthBodyLimitTests and CustomerAndOrderTests.

{
// A NON-400 BadHttpRequestException — 413 from the #309
// request-body cap's rare non-JSON-bound-endpoint escape, 415
// from an unrecognised Content-Type, … — is NOT this
// middleware's concern. Rethrow untouched so it keeps
// flowing to the existing /error mapping exactly as before
// this change: RequestBodyLimit.cs's WriteBodyTooLargeAsync
// depends on THAT handler staying byte-identical to its own
// 413 shape.
//
// Same for a response that has already started: writing here
// would throw a second, more confusing exception on top of
// the first (Response.HasStarted), so rethrow and let it
// propagate as an ordinary unhandled exception instead.
if (ex.StatusCode != StatusCodes.Status400BadRequest || context.Response.HasStarted)
throw;
Comment on lines +71 to +72

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve streamed 413 handling when binding throws

When a capped JSON endpoint receives a chunked body or a body whose declared length is below the cap but whose actual bytes exceed it, ByteCappedRequestStream throws a 413 during binding. With the new global ThrowOnBadRequest=true, that exception is no longer swallowed by the generated binder, and rethrowing it here unwinds past RequestBodyLimit's post-next() recovery at RequestBodyLimit.cs:70-87. Consequently Serilog again records a 500/Error completion and /error returns "Request body too large." instead of the canonical cap-specific body, breaking the contract pinned by AuthBodyLimitTests.Declared_length_and_chunked_413_bodies_have_the_same_shape; handle this 413 inside Serilog as well while preserving its 413 response.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took this seriously and went to measure it, because the mechanism you describe is exactly right and would be a real regression. It does not reproduce — but the gap you identified in the coverage was real, so there is now a test either way (1e1435c).

Your reasoning about the recovery is correct. RequestBodyLimit's handling of the streamed case is a post-next() status check, not a catch:

await next();
if (context.Response.StatusCode == StatusCodes.Status413PayloadTooLarge && !context.Response.HasStarted)
    await WriteBodyTooLargeAsync(context, meta.Bytes);

Its own comment states the premise: minimal-API's generated binder "CATCHES the BadHttpRequestException itself, sets Response.StatusCode = 413, and returns WITHOUT rethrowing." If ThrowOnBadRequest=true invalidated that, next() would throw, the recovery would be skipped, BindingFailureResponse would rethrow (413 ≠ 400), and Serilog would log its hardcoded 500/Error. That is a sound prediction.

Measured result: the premise still holds. ThrowOnBadRequest governs how the generated binder reports its own binding failures; an exception propagating out of the request stream during a read is handled by different logic in that generated code, and is still swallowed into Response.StatusCode = 413. So the recovery still runs, and the canonical body is still what the client gets.

Evidence:

  • AuthBodyLimitTests — 9/9 green on this branch, including Declared_length_and_chunked_413_bodies_have_the_same_shape. Its ProblemFields helper compares title, detail and status, so had the chunked path fallen through to /error it would have compared "The request body exceeds the 4096-byte limit for this endpoint." against bad.Message and failed. It does not.
  • New RequestLoggingTests.Streamed_413_logs_one_completion_at_information_with_status_413 — asserts the completion is StatusCode 413, Level Information, Exception null. Green.

On the coverage point you were right about: no test could see this. AuthBodyLimitTests inspects the HTTP response only, and a mis-logged completion is invisible to every response-level assertion in the suite — which is precisely how the original defect on the 400 path survived. So the new test asserts the telemetry directly rather than inferring it from the response.

Mutation-checked against your exact scenario, so it is not a test that merely happens to pass: making the recovery throw new BadHttpRequestException("Request body too large.", 413) instead of writing the body — i.e. forcing the propagation you predicted — turns the new test RED; restoring it turns it green. 17/17 across both classes.

One implementation note: it filters by status rather than Assert.Single on the path, because the sink is class-scoped and never reset and several other tests here reach /api/v1/auth/login through the login helper. Exactly one 413 completion is still the assertion, so a re-executed /error would add a second and a mis-logged 500 would leave zero.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took this seriously and went to measure it, because the mechanism you describe is exactly right and would be a real regression. It does not reproduce — but the gap you identified in the coverage was real, so there is now a test either way (1e1435c).

Your reasoning about the recovery is correct. RequestBodyLimit's handling of the streamed case is a post-next() status check, not a catch:

await next();
if (context.Response.StatusCode == StatusCodes.Status413PayloadTooLarge && !context.Response.HasStarted)
    await WriteBodyTooLargeAsync(context, meta.Bytes);

Its own comment states the premise: minimal-API's generated binder "CATCHES the BadHttpRequestException itself, sets Response.StatusCode = 413, and returns WITHOUT rethrowing." If ThrowOnBadRequest=true invalidated that, next() would throw, the recovery would be skipped, BindingFailureResponse would rethrow (413 ≠ 400), and Serilog would log its hardcoded 500/Error. That is a sound prediction.

Measured result: the premise still holds. ThrowOnBadRequest governs how the generated binder reports its own binding failures; an exception propagating out of the request stream during a read is handled by different logic in that generated code, and is still swallowed into Response.StatusCode = 413. So the recovery still runs, and the canonical body is still what the client gets.

Evidence:

* `AuthBodyLimitTests` — **9/9 green** on this branch, including `Declared_length_and_chunked_413_bodies_have_the_same_shape`. Its `ProblemFields` helper compares `title`, `detail` **and** `status`, so had the chunked path fallen through to `/error` it would have compared `"The request body exceeds the 4096-byte limit for this endpoint."` against `bad.Message` and failed. It does not.

* New `RequestLoggingTests.Streamed_413_logs_one_completion_at_information_with_status_413` — asserts the completion is **StatusCode 413, Level Information, Exception null**. Green.

On the coverage point you were right about: no test could see this. AuthBodyLimitTests inspects the HTTP response only, and a mis-logged completion is invisible to every response-level assertion in the suite — which is precisely how the original defect on the 400 path survived. So the new test asserts the telemetry directly rather than inferring it from the response.

Mutation-checked against your exact scenario, so it is not a test that merely happens to pass: making the recovery throw new BadHttpRequestException("Request body too large.", 413) instead of writing the body — i.e. forcing the propagation you predicted — turns the new test RED; restoring it turns it green. 17/17 across both classes.

One implementation note: it filters by status rather than Assert.Single on the path, because the sink is class-scoped and never reset and several other tests here reach /api/v1/auth/login through the login helper. Exactly one 413 completion is still the assertion, so a re-executed /error would add a second and a mis-logged 500 would leave zero.

@codex see above

Comment on lines +71 to +72

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep binding-generated 415s out of error telemetry

When a request with an unsupported Content-Type reaches any JSON-bound endpoint, the new global ThrowOnBadRequest = true makes the generated binder throw a BadHttpRequestException with status 415. In the inspected Program.cs pipeline, this condition rethrows every non-400 exception through Serilog, which records a hardcoded 500/Error completion even though /error ultimately returns 415; ordinary client mistakes can therefore still inflate 5xx alerts. Handle binding-generated 415s inside this middleware or explicitly classify them in GetLevel; the streamed-413 test does not cover this path because that exception is swallowed by the binder.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Measured it rather than reasoning about it, and it does not reproduce — but the guarantee is now pinned either way (98a955a).

Your premise was right and it is the distinction I drew myself: ThrowOnBadRequest governs the binder's own binding failures, and an unsupported media type is one of those — unlike ByteCappedRequestStream's 413, which propagates out of the request stream and is still swallowed. So if a 415 were generated by the binder, it would throw, BindingFailureResponse would rethrow it (not a 400), and Serilog would record its hardcoded 500/Error. That reasoning is sound.

No 415 is ever produced. A JSON-bound minimal-API endpoint carries a content-type constraint on the route, so a form-encoded body fails to match the endpoint and is refused with 404 before any binder runs. I wrote the test expecting 415 and it failed with Expected: UnsupportedMediaType, Actual: NotFound. No exception exists, so there is nothing for the middleware to rethrow and nothing for Serilog to mis-classify.

Kept the test anyway, rewritten around the guarantee instead of the status: an ordinary client mistake must never reach Error level. It asserts against whatever status the path actually returns rather than hardcoding 404 twice — so if a future change does start generating a real 415 through the binder, it keeps proving the telemetry guarantee rather than merely failing on a status mismatch.

Mutation-checked so it is not vacuous: escalating GetLevel to treat 404+ as Error turns it RED; restored, green. 18/18 across RequestLoggingTests and AuthBodyLimitTests, build 0 warnings 0 errors.

Worth noting the round-2 and round-3 findings landed on opposite sides of the same line: the 413 is not thrown because it is not the binder's own failure, and the 415 is not thrown because it never gets to the binder. Both are now covered by a test rather than by an argument.


// #398 review round 4 (Codex) — a failed TYPED QUERY PARAMETER
// throws this same 400, so blaming the body unconditionally lies
// to the caller of a bodyless GET.
//
// Round 5 (Codex) corrected HOW to tell them apart. The first
// attempt asked whether the REQUEST carried payload bytes, which
// inverts for the case that matters most: a caller who omits a
// REQUIRED body sends no bytes, yet that is a body failure, and
// it was being reported as a query one.
//
// So ask the ENDPOINT what it accepts, not the request what it
// sent. IAcceptsMetadata is present with a RequestType exactly
// when a route declares a request body — for those, a binding
// failure is a body failure whether the body was malformed or
// absent. The byte check stays only as a fallback for the
// (unexpected) case of no endpoint metadata.
//
// Still deliberately NOT ex.Message: the binding messages are
// framework internals that differ per binding source and can
// change between versions.
await ValidationResponse
.BindingFailureProblem(ConcernsRequestBody(context))
.ExecuteAsync(context);
}
});

// Shared by the middleware above and Program.cs's `/error` backstop, so the
// two can never disagree about which key a binding failure is reported
// under — the same reason the response shape itself is a single factory.
//
// #398 review round 6 (Codex) — the byte checks are a fallback for "no
// endpoint was matched", and must therefore be BRANCHED, not OR-ed. Or-ing
// them let incidental payload bytes on a query-only route report `body`:
// a GET to /api/v1/reports/production?from=not-a-date carrying a non-empty
// body has ContentLength > 0, so the expression returned true even though
// that endpoint accepts no body at all. The comment claimed "fallback"
// while the code applied the checks unconditionally — the code is now what
// the comment says.
//
// When an endpoint IS matched, its own contract is authoritative and
// nothing about the request can override it.
public static bool ConcernsRequestBody(HttpContext context)
{
// #398 review round 8 (found by BodyReadingEndpointTests, not by report)
// — the `/error` backstop calls this from INSIDE exception-handler
// re-execution, and that re-execution clears the endpoint and routes
// again, so context.GetEndpoint() there is the `/error` endpoint itself.
// `/error` declares no body and carries no marker, so once round 6 made a
// matched endpoint authoritative, the backstop answered `query` for
// EVERY failure it handled, including a malformed JSON body.
//
// IExceptionHandlerFeature.Endpoint is the framework's own record of
// what was matched before the throw — the original contract, which is
// the one this question is about. Outside re-execution the feature is
// absent and the current endpoint is already the right one.
var endpoint = context.Features.Get<IExceptionHandlerFeature>()?.Endpoint
?? context.GetEndpoint();
if (endpoint is not null)
// IAcceptsMetadata covers endpoints with a declared typed body.
// ReadsRequestBodyAttribute covers the ones that read it manually
// and therefore declare nothing — see the attribute's own comment.
return endpoint.Metadata.GetMetadata<IAcceptsMetadata>()?.RequestType is not null
|| endpoint.Metadata.GetMetadata<ReadsRequestBodyAttribute>() is not null;
Comment on lines +135 to +136

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mark the refresh endpoint as a body reader

Fresh evidence beyond the prior thread is AuthEndpoints.Refresh: it explicitly drains request.Body with CopyToAsync at AuthEndpoints.cs:227 so the 1 KB stream cap is enforced. If that read raises a 400 BadHttpRequestException—for example, because a client declares a longer body than it sends—the endpoint has neither typed-body metadata nor ReadsRequestBodyAttribute, so this branch returns false and reports errors.query even though /auth/refresh has no query input and the failure came from its body read. Add the marker to the refresh registration as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real, and my round-7 sweep was wrong about this exact endpoint. I read the comment above the drain — "the token rides in the cookie" — and stopped three lines short of await request.Body.CopyToAsync(Stream.Null, ct). Marker added in 0e2d39e.

But two missed markers in two consecutive rounds is a defect in the method, not in two endpoints, so the commit also adds BodyReadingEndpointTests. It walks the running app's own EndpointDataSource and requires every handler that can reach the raw body (HttpContext/HttpRequest/Stream/PipeReader) to either declare a typed body, carry ReadsRequestBodyAttribute, or appear in an exemption list with a stated reason. A second test fails a stale exemption, so a renamed route can't leave one behind to excuse a later endpoint that lands on the same key. A third missing marker is now a red test rather than a review round.

It found one on its first run, and not on an endpoint: the /error backstop was structurally always answering query. UseExceptionHandler re-executes the pipeline, which clears the endpoint and routes again — so context.GetEndpoint() inside /error is the /error endpoint, which declares no body. Once round 6 made a matched endpoint authoritative, that made the backstop report query for every failure it handled, including malformed JSON. Fixed by preferring IExceptionHandlerFeature.Endpoint, the framework's own record of what was matched before the throw; outside re-execution the feature is absent and nothing changes.

Verification — baseline green, 122/122 across the affected classes, build 0 warnings:

  • drop the refresh marker → guard red, naming POST /api/v1/auth/refresh → AuthEndpoints.Refresh
  • revert to context.GetEndpoint() → During_error_reexecution_the_original_endpoint_wins red, other four classification cases green
  • fabricate an exemption → stale-exemption test red

One note on the fix I did not take: .Accepts<T>() also imposes a content-type constraint on the route, so it would change which requests match. The marker only changes how a failure is described.

Also checked /auth/logout — it takes HttpRequest but reads the cookie and CSRF header only, never Body. It's in the exemption list with that reason rather than left silent.


// No endpoint (404, or a failure before routing resolved one): fall
// back to whether the request carried a payload at all.
return context.Request.ContentLength > 0
|| context.Request.Headers.TransferEncoding.Count > 0;
}
}
81 changes: 78 additions & 3 deletions src/Cluckwork.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
using Cluckwork.Api.Hosting;
using Cluckwork.Api.Middleware;
using Cluckwork.Api.Security;
using Cluckwork.Api.Validation;
using Cluckwork.Infrastructure.Persistence;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics;
Expand Down Expand Up @@ -59,6 +60,23 @@
builder.Services.Configure<IdempotencyOptions>(
builder.Configuration.GetSection(IdempotencyOptions.SectionName));

// #398 — ASP.NET Core's minimal-API parameter binding defaults
// RouteHandlerOptions.ThrowOnBadRequest to true only in Development; outside
// it (Testing, Production — everywhere this app actually runs client
// traffic) a binding failure (malformed JSON, a fractional quantity into an
// `int`, an unparseable date/guid, …) is swallowed INSIDE the framework's
// generated code: it sets Response.StatusCode itself and returns WITHOUT
// throwing, so UseExceptionHandler's `/error` mapping below never runs at
// all — the client gets a bare 400 with an EMPTY body, not even a generic
// ProblemDetails. Force the throw in every environment so this app's own
// `/error` handler is always the thing shaping the response, instead of that
// environment-conditional framework default. (Only ONE test factory —
// RequestLoggingFactory — used to carry its own copy of this override, added
// because ITS test specifically needed the real throw; this registration
// supersedes it globally, so that copy was removed.)
builder.Services.Configure<Microsoft.AspNetCore.Routing.RouteHandlerOptions>(
o => o.ThrowOnBadRequest = true);
Comment on lines +77 to +78

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Classify binding failures as client errors in request logs

When a client sends malformed JSON or a value of the wrong primitive type, this production-wide setting turns an ordinary 400 binding rejection into an exception. The existing Serilog callback in Program.cs classifies any exception as Error, and RequestLoggingTests.Unhandled_exception_logs_one_completion_for_the_real_path_only explicitly observes a 500 completion for a response that is actually 400. Routine bad requests and scanner traffic will therefore inflate 5xx/error telemetry and may trigger false alerts; enabling the global throw needs a matching BadHttpRequestException/400 logging classification and status.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 9e59328. I verified the mechanism before fixing it rather than taking the report on trust, and it is worse than "the callback classifies exceptions as Error" — the recorded status is wrong too, and GetLevel alone cannot repair that.

app.UseExceptionHandler(...) is registered at Program.cs:186, above app.UseSerilogRequestLogging(...) at :211. So a propagating exception reaches Serilog first, and Serilog.AspNetCore's RequestLoggingMiddleware catches it as catch (Exception ex) when (LogCompletion(httpContext, collector, 500, ex)) — a hardcoded 500, ignoring Response.StatusCode entirely. Adding a BadHttpRequestException arm to GetLevel would therefore have fixed the level and still logged StatusCode 500 for a request the client sees as 400.

So the fix stops the exception reaching Serilog at all. A new middleware, UseCluckworkBindingFailureResponse() (Hosting/BindingFailureResponse.cs), is registered immediately after UseSerilogRequestLogging — i.e. inside it — catches a BadHttpRequestException with StatusCode == 400, writes the ValidationProblem body itself, and does not rethrow. Serilog then sees an ordinary completed 400 with no exception in flight and logs Information / 400, exactly like any other well-formed 400. No /error re-execution happens either.

Two things deliberately preserved:

  • A non-400 BadHttpRequestException (413 from the Bound authentication request bodies and credential inputs before password hashing #309 body cap, 415 from an unrecognised Content-Type) is rethrown untouched and still reaches the existing /error mapping — RequestBodyLimit.WriteBodyTooLargeAsync depends on that 413 shape staying byte-identical.
  • If Response.HasStarted, it rethrows rather than writing, so a partially-sent response does not turn into a second, more confusing exception.

The response shape is now produced by a single factory, ValidationResponse.BindingFailureProblem(), called by both the middleware and the /error backstop, so the two cannot drift. I confirmed by grep that the message literal appears exactly once in src/.

No BadHttpRequestException arm was added to GetLevel — it would be dead code now, and there is an in-code comment saying so rather than leaving a reader to wonder whether it was forgotten.

Verification. RequestLoggingTests gained Binding_failure_logs_one_completion_at_information_with_status_400, and the pre-existing Unhandled_exception_logs_one_completion_for_the_real_path_only was reworked to trigger via a genuine fault (a throwing validator) rather than malformed JSON, since malformed JSON no longer reaches Serilog as an exception — so it still proves Error/500 holds for a real fault.

Three mutations, each killing exactly the test that claims to cover it and leaving the other green:

  1. Unregister the middleware → the new test goes RED with StatusCode 500, Level Error — the exact bug you reported.
  2. GetLevel's Error branch → Information → the unhandled-exception test goes RED.
  3. Drift BindingFailureProblem()'s text → the new test goes RED on the exact-string assertion, proving the middleware really calls the shared factory rather than a copy.

Each restored and reconfirmed green. Full suite: Domain 283/283, Application 114/114, Integration 761/761.


// --- OpenAPI ---
builder.Services.AddOpenApi();

Expand Down Expand Up @@ -215,6 +233,17 @@
// suite runs many) reassigns and disposes — completions would silently go
// to another host's pipeline.
options.Logger = app.Services.GetRequiredService<Serilog.ILogger>();
// No dedicated BadHttpRequestException arm here (#398 review, Codex): a
// 400 binding failure is now caught by UseCluckworkBindingFailureResponse
// immediately below, which does NOT rethrow, so by the time a completion
// reaches this callback it is either (a) an ordinary sub-500 response
// with `exception` null — falls through to Information via the existing
// status-code check, no special-casing needed — or (b) a genuine
// unhandled exception (anything that middleware didn't catch, including
// a non-400 BadHttpRequestException like the #309 413/415 cases), which
// correctly stays Error. Adding an arm for the exception TYPE here would
// be dead code: nothing reaching this point is a 400
// BadHttpRequestException anymore.
options.GetLevel = (httpContext, _, exception) =>
// Health first — a FAILING probe (503 during a DB outage) must not
// escalate to Error while orchestrators poll every few seconds. The
Expand All @@ -229,6 +258,13 @@
: Serilog.Events.LogEventLevel.Information;
});

// #398 review (Codex) — must be registered IMMEDIATELY after
// UseSerilogRequestLogging (i.e. INSIDE it), so a JSON-binding failure is
// caught and answered here, before Serilog's own exception handling ever
// sees it. Full reasoning in Hosting/BindingFailureResponse.cs. Does not
// change the relative order of anything already below it.
app.UseCluckworkBindingFailureResponse();

// Endpoint rate-limit policies (#143) — no global limiter, only routes that
// opt in via RequireRateLimiting are affected.
app.UseRateLimiter();
Expand Down Expand Up @@ -396,9 +432,48 @@
var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
return exception switch
{
// Minimal-API body binding failures (malformed JSON, unparseable
// dates/guids) throw this with a 400 — without the mapping the
// exception handler swallowed it into a 500.
// #398 — a JSON-binding failure (a fractional quantity into an `int`
// field, an unparseable date/guid, malformed JSON syntax, …) throws
// this deep inside minimal-API's generated body-reader, BEFORE any
// FluentValidation validator or handler runs. bad.Message names the
// exact parameter/type it failed to bind (e.g. "Failed to read
// parameter \"AddOrderItemRequest request\" from the request body as
// JSON.") and its InnerException carries the raw System.Text.Json /
// FormatException detail — both are framework internals, never meant
// for an end user, and echoing them (as this branch used to) leaked
// parameter/type names straight into the SPA's error banner. Route
// THIS specific case — always StatusCode 400 by construction, the
// same default every JSON-binding BadHttpRequestException carries —
// through the SAME ValidationProblem shape every other 400 in this
// app uses (ValidationResponse.BindingFailureProblem), instead of
// echoing the exception text.
//
// #398 review (Codex) — this branch is now a BACKSTOP, not the
// primary path: UseCluckworkBindingFailureResponse
// (Hosting/BindingFailureResponse.cs) intercepts the overwhelming
// majority of these before they ever reach here, specifically so
// Serilog's request-logging middleware (which sits between it and
// here) never sees the exception and mis-logs the completion as a
// 500 at Error for what the client correctly receives as a 400. This
// mapping stays — unreachable only in the ordinary case — for any
// binding failure that somehow bypasses that middleware (e.g. the
// response had already started).
//
// A NON-400 BadHttpRequestException (413 from the #309 request-body
// cap, 415 from an unrecognised Content-Type, …) falls through to the
// unchanged branch below: RequestBodyLimit.cs's WriteBodyTooLargeAsync
// depends on THIS handler staying byte-identical to its own 413 shape
// for the (rare, non-JSON-bound-endpoint) case that reaches /error
// instead of being short-circuited there — don't fold that case into
// the 400-only ValidationProblem mapping above.
// Same body-vs-query decision as the primary path, via the one shared
// helper: a bodyless GET whose typed query parameter failed to bind must
// not be told its body was malformed, and a caller who omitted a
// REQUIRED body must not be told the problem was a query parameter
// (#398 review rounds 4 and 5).
BadHttpRequestException { StatusCode: StatusCodes.Status400BadRequest } =>
ValidationResponse.BindingFailureProblem(
BindingFailureResponse.ConcernsRequestBody(context)),
BadHttpRequestException bad => Results.Problem(
detail: bad.Message,
statusCode: bad.StatusCode,
Expand Down
Loading