using System.Text.Json; using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Moq; using SatelliteProvider.Api; namespace SatelliteProvider.Tests; public class GlobalExceptionHandlerTests { [Fact] public async Task TryHandleAsync_WritesSanitizedProblemDetailsAndReturnsTrue_AC1() { // Arrange var loggerMock = new Mock>(); var handler = new GlobalExceptionHandler(loggerMock.Object); var httpContext = new DefaultHttpContext { TraceIdentifier = "trace-12345" }; httpContext.Request.Method = "POST"; httpContext.Request.Path = "/api/satellite/route"; var body = new MemoryStream(); httpContext.Response.Body = body; var leakySecret = "Connection string Host=secret-db;Password=hunter2 failed at line 42"; var exception = new InvalidOperationException(leakySecret); // Act var handled = await handler.TryHandleAsync(httpContext, exception, CancellationToken.None); // Assert handled.Should().BeTrue(); httpContext.Response.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); httpContext.Response.ContentType.Should().Contain("application/problem+json"); body.Position = 0; using var doc = JsonDocument.Parse(body); var root = doc.RootElement; root.GetProperty("status").GetInt32().Should().Be(500); root.GetProperty("title").GetString().Should().Be("Internal Server Error"); root.GetProperty("detail").GetString().Should().NotContain("hunter2"); root.GetProperty("detail").GetString().Should().NotContain("secret-db"); root.GetProperty("correlationId").GetString().Should().Be("trace-12345"); } [Fact] public async Task TryHandleAsync_BadHttpRequestException_HonorsFrameworkStatusAndDoesNotErrorLog_AC3() { // Arrange var loggerMock = new Mock>(); var handler = new GlobalExceptionHandler(loggerMock.Object); var httpContext = new DefaultHttpContext { TraceIdentifier = "trace-bind-fail" }; httpContext.Response.Body = new MemoryStream(); var bindFailure = new BadHttpRequestException( "Failed to bind parameter \"double Latitude\" from \"abc\".", StatusCodes.Status400BadRequest); // Act var handled = await handler.TryHandleAsync(httpContext, bindFailure, CancellationToken.None); // Assert handled.Should().BeTrue("the handler writes the response itself rather than promoting to 5xx"); httpContext.Response.StatusCode.Should().Be(StatusCodes.Status400BadRequest, "framework-level request errors must keep their intended 4xx status, not become 500"); loggerMock.Verify( l => l.Log( LogLevel.Error, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>()), Times.Never, "BadHttpRequestException is a client error and must not be ERROR-logged as a server failure"); } [Fact] public async Task TryHandleAsync_LogsFullExceptionWithCorrelationId_AC2() { // Arrange var loggerMock = new Mock>(); var handler = new GlobalExceptionHandler(loggerMock.Object); var httpContext = new DefaultHttpContext { TraceIdentifier = "trace-AC2" }; httpContext.Request.Method = "GET"; httpContext.Request.Path = "/api/satellite/region/abc"; httpContext.Response.Body = new MemoryStream(); var exception = new InvalidOperationException("inner failure detail"); // Act await handler.TryHandleAsync(httpContext, exception, CancellationToken.None); // Assert loggerMock.Verify( l => l.Log( LogLevel.Error, It.IsAny(), It.Is((state, _) => state.ToString()!.Contains("trace-AC2") && state.ToString()!.Contains("/api/satellite/region/abc") && state.ToString()!.Contains("GET")), exception, It.IsAny>()), Times.Once); } }