diff --git a/src/ProjectTemplate.Web/ErrorHandling/ProblemDetailsExceptionHandler.cs b/src/ProjectTemplate.Web/ErrorHandling/ProblemDetailsExceptionHandler.cs index b3106a6..067fc86 100644 --- a/src/ProjectTemplate.Web/ErrorHandling/ProblemDetailsExceptionHandler.cs +++ b/src/ProjectTemplate.Web/ErrorHandling/ProblemDetailsExceptionHandler.cs @@ -98,9 +98,10 @@ private static int GetStatusCode(Exception exception) return exception switch { BadHttpRequestException => StatusCodes.Status400BadRequest, - ArgumentException => StatusCodes.Status400BadRequest, UnauthorizedAccessException => StatusCodes.Status403Forbidden, TimeoutException => StatusCodes.Status503ServiceUnavailable, + // Plain ArgumentException is intentionally treated as an internal failure. Broad argument failures can + // represent server-side developer bugs; request-level failures should use explicit client-input types. _ => StatusCodes.Status500InternalServerError }; } diff --git a/tests/ProjectTemplate.Web.Tests/ProblemDetailsExceptionHandlerTests.cs b/tests/ProjectTemplate.Web.Tests/ProblemDetailsExceptionHandlerTests.cs index 7154a9d..fb945ca 100644 --- a/tests/ProjectTemplate.Web.Tests/ProblemDetailsExceptionHandlerTests.cs +++ b/tests/ProjectTemplate.Web.Tests/ProblemDetailsExceptionHandlerTests.cs @@ -18,7 +18,7 @@ public sealed class ProblemDetailsExceptionHandlerTests new() { { ProblemDetailsExceptionCase.BadHttpRequest, StatusCodes.Status400BadRequest, "Bad Request" }, - { ProblemDetailsExceptionCase.Argument, StatusCodes.Status400BadRequest, "Bad Request" }, + { ProblemDetailsExceptionCase.Argument, StatusCodes.Status500InternalServerError, "Internal Server Error" }, { ProblemDetailsExceptionCase.UnauthorizedAccess, StatusCodes.Status403Forbidden, "Forbidden" }, { ProblemDetailsExceptionCase.Timeout, StatusCodes.Status503ServiceUnavailable, "Service Unavailable" }, { ProblemDetailsExceptionCase.Unknown, StatusCodes.Status500InternalServerError, "Internal Server Error" } @@ -30,9 +30,9 @@ public sealed class ProblemDetailsExceptionHandlerTests [Theory] [MemberData(nameof(ExceptionMappings))] public async Task TryHandleAsync_ExceptionMappings_WriteExpectedProblemDetails( - ProblemDetailsExceptionCase exceptionCase, - int expectedStatusCode, - string expectedTitle) + ProblemDetailsExceptionCase exceptionCase, + int expectedStatusCode, + string expectedTitle) { Exception exception = CreateException(exceptionCase); @@ -96,12 +96,12 @@ public async Task TryHandleAsync_UnknownProductionException_DoesNotExposeRawExce } /// - /// Verifies that production client-error responses do not expose raw exception messages. + /// Verifies that production request-level client-error responses do not expose raw exception messages. /// [Fact] - public async Task TryHandleAsync_ProductionClientException_DoesNotExposeRawExceptionDetail() + public async Task TryHandleAsync_ProductionBadHttpRequestException_DoesNotExposeRawExceptionDetail() { - const string SensitiveMessage = "Sensitive validation internals leak marker."; + const string SensitiveMessage = "Sensitive request parser leak marker."; CapturingProblemDetailsService problemDetailsService = new(); ProblemDetailsExceptionHandler handler = CreateHandler( @@ -112,7 +112,7 @@ public async Task TryHandleAsync_ProductionClientException_DoesNotExposeRawExcep bool handled = await handler.TryHandleAsync( httpContext, - new ArgumentException(SensitiveMessage), + new BadHttpRequestException(SensitiveMessage, StatusCodes.Status400BadRequest), TestContext.Current.CancellationToken); Assert.True(handled); @@ -128,6 +128,39 @@ public async Task TryHandleAsync_ProductionClientException_DoesNotExposeRawExcep StringComparison.Ordinal); } + /// + /// Verifies that production ArgumentException responses are treated as server errors and hide raw details. + /// + [Fact] + public async Task TryHandleAsync_ProductionArgumentException_IsServerErrorAndDoesNotExposeRawExceptionDetail() + { + const string SensitiveMessage = "Sensitive internal argument failure marker."; + + CapturingProblemDetailsService problemDetailsService = new(); + ProblemDetailsExceptionHandler handler = CreateHandler( + problemDetailsService, + Environments.Production); + + DefaultHttpContext httpContext = CreateProblemDetailsHttpContext(); + + bool handled = await handler.TryHandleAsync( + httpContext, + new ArgumentException(SensitiveMessage), + TestContext.Current.CancellationToken); + + Assert.True(handled); + Assert.NotNull(problemDetailsService.Context); + + ProblemDetails problemDetails = problemDetailsService.Context.ProblemDetails; + + Assert.Equal(StatusCodes.Status500InternalServerError, problemDetails.Status); + Assert.Equal("Internal Server Error", problemDetails.Title); + Assert.Equal( + "An unexpected error occurred. Contact support with the request ID.", + problemDetails.Detail); + Assert.DoesNotContain(SensitiveMessage, problemDetails.Detail, StringComparison.Ordinal); + } + /// /// Verifies that development responses include the exception message for diagnostics. ///