Files
satellite-provider/SatelliteProvider.Tests/GlobalExceptionHandlerTests.cs
T
Oleksandr Bezdieniezhnykh 865dfdb3b9
ci/woodpecker/push/01-test Pipeline was successful
ci/woodpecker/push/02-build-push Pipeline was successful
[AZ-794] [AZ-795] [AZ-796] Strict input validation + z/x/y rename
AZ-794: rename inventory wire fields tileZoom/tileX/tileY -> z/x/y
to match the slippy-map URL convention. Contract bumped to v2.0.0.

AZ-795: shared validation infrastructure -- FluentValidation +
ValidationEndpointFilter + GlobalValidatorConfig (camelCase paths).
GlobalExceptionHandler now converts JsonException (UnmappedMember +
JsonRequired) into RFC 7807 ValidationProblemDetails. JSON layer
hardened with UnmappedMemberHandling.Disallow + camelCase naming
policy. New error-shape.md contract.

AZ-796: InventoryRequestValidator covers 9 rules (XOR tiles vs
locationHashes, cap 1000, z 0..22, x/y in slippy bounds, hash
length/charset). 16 unit tests + 16 integration tests + a manual
curl probe script.

Adjacent fixes uncovered by the new strict layer:
- IdempotentPostTests RoutePoint payload corrected to lat/lon
  (the DTO has used JsonPropertyName for ages; previously silently
  ignored under PascalCase fallback).
- TileInventoryTests slippy x/y reduced to fit z=18 bounds.
- docker-compose.yml host port for Postgres moved 5432 -> 5433 to
  avoid sibling-project conflict; appsettings.Development + README
  + AGENTS + architecture + containerization docs aligned.

New coderule (suite + repo): API consumer-facing OpenAPI
descriptions must not contain task IDs, contract filenames, or
version-bump history -- internal change tracking belongs in
commits/contract docs/changelogs. Existing offending descriptions
in Program.cs cleaned up.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 10:02:02 +03:00

143 lines
6.1 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().Contain("could not be mapped");
}
[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);
}
}