mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-26 03:41:13 +00:00
172 lines
7.4 KiB
C#
172 lines
7.4 KiB
C#
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<ILogger<GlobalExceptionHandler>>();
|
|
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<ILogger<GlobalExceptionHandler>>();
|
|
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<EventId>(),
|
|
It.IsAny<It.IsAnyType>(),
|
|
It.IsAny<Exception?>(),
|
|
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
|
Times.Never,
|
|
"BadHttpRequestException is a client error and must not be ERROR-logged as a server failure");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryHandleAsync_DeserializationFailure_WritesValidationProblemDetailsWithJsonPath_AZ795()
|
|
{
|
|
// Arrange
|
|
var loggerMock = new Mock<ILogger<GlobalExceptionHandler>>();
|
|
var handler = new GlobalExceptionHandler(loggerMock.Object);
|
|
var httpContext = new DefaultHttpContext { TraceIdentifier = "trace-AZ795" };
|
|
httpContext.Response.Body = new MemoryStream();
|
|
var jsonInner = new JsonException(
|
|
"The JSON property 'foo' could not be mapped to any .NET member contained in type 'TileInventoryRequest'.",
|
|
"$.tiles[0].foo",
|
|
lineNumber: null,
|
|
bytePositionInLine: null);
|
|
var bindFailure = new BadHttpRequestException(
|
|
"Failed to read parameter \"TileInventoryRequest request\" from request body.",
|
|
StatusCodes.Status400BadRequest,
|
|
jsonInner);
|
|
|
|
// Act
|
|
var handled = await handler.TryHandleAsync(httpContext, bindFailure, CancellationToken.None);
|
|
|
|
// Assert
|
|
handled.Should().BeTrue();
|
|
httpContext.Response.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
|
|
httpContext.Response.ContentType.Should().Contain("application/problem+json");
|
|
|
|
httpContext.Response.Body.Position = 0;
|
|
using var doc = JsonDocument.Parse(httpContext.Response.Body);
|
|
var root = doc.RootElement;
|
|
|
|
root.GetProperty("status").GetInt32().Should().Be(400);
|
|
root.GetProperty("title").GetString().Should().Be("One or more validation errors occurred.");
|
|
root.GetProperty("type").GetString().Should().Be("https://tools.ietf.org/html/rfc7231#section-6.5.1");
|
|
root.GetProperty("errors")
|
|
.GetProperty("tiles[0].foo")[0]
|
|
.GetString()
|
|
.Should().Be("The field value is invalid.");
|
|
root.GetProperty("errors")
|
|
.GetProperty("tiles[0].foo")[0]
|
|
.GetString()
|
|
.Should().NotContain("TileInventoryRequest");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryHandleAsync_BadHttpRequestExceptionWithoutJson_UsesStaticDetail()
|
|
{
|
|
// Arrange
|
|
var loggerMock = new Mock<ILogger<GlobalExceptionHandler>>();
|
|
var handler = new GlobalExceptionHandler(loggerMock.Object);
|
|
var httpContext = new DefaultHttpContext { TraceIdentifier = "trace-bind-static" };
|
|
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();
|
|
httpContext.Response.Body.Position = 0;
|
|
using var doc = JsonDocument.Parse(httpContext.Response.Body);
|
|
doc.RootElement.GetProperty("detail").GetString()
|
|
.Should().Be("The request could not be processed.");
|
|
doc.RootElement.GetProperty("detail").GetString()
|
|
.Should().NotContain("Latitude");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryHandleAsync_LogsFullExceptionWithCorrelationId_AC2()
|
|
{
|
|
// Arrange
|
|
var loggerMock = new Mock<ILogger<GlobalExceptionHandler>>();
|
|
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<EventId>(),
|
|
It.Is<It.IsAnyType>((state, _) =>
|
|
state.ToString()!.Contains("trace-AC2") &&
|
|
state.ToString()!.Contains("/api/satellite/region/abc") &&
|
|
state.ToString()!.Contains("GET")),
|
|
exception,
|
|
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
|
Times.Once);
|
|
}
|
|
}
|