mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-22 20:01:14 +00:00
[AZ-353][AZ-354][AZ-356] Refactor 03 batch 2: harden API surface
AZ-353: Centralize 500 handling via GlobalExceptionHandler / AddProblemDetails / UseExceptionHandler. Sanitized ProblemDetails body carries a generic title, RFC9110 type link, and the request's TraceIdentifier as correlationId; the leaky exception message stays server-side in the ERR log entry. Strip per-endpoint try/catch (Exception) wrappers and the unused ILogger<Program> parameters they served. Preserve the typed ArgumentException catch in CreateRoute (AC-3). The handler maps BadHttpRequestException back to its framework-supplied StatusCode so model-binding / malformed-body failures stay 4xx instead of being promoted to 500. AZ-354: Extract CorsConfigurationValidator (pure static helpers) and wire it into Program.cs. Production with empty CorsConfig:AllowedOrigins and no CorsConfig:AllowAnyOrigin opt-in now throws InvalidOperationException at host startup. Development keeps the permissive default but logs a warning post-build. Adds the explicit CorsConfig:AllowAnyOrigin escape hatch. AZ-356: GetSatelliteTilesByMgrs and UploadImage now return Results.Problem(StatusCode 501) with ProblemDetails. Added .ProducesProblem(501) so swagger.json documents the not-implemented status. Tests: SatelliteProvider.Tests now references SatelliteProvider.Api (downward, idiomatic) so unit tests can reach the new helpers. +9 CorsConfigurationValidator unit tests, +3 GlobalExceptionHandler unit tests, +3 StubAndErrorContractTests integration tests (added to smoke + full suites). 58/58 unit + 5/5 smoke + 3/3 stub-contract pass. Code review verdict: PASS. Batch report: _docs/03_implementation/batch_08_report.md. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
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_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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user