Files
ColaFlow/colaflow-api/tests/Modules/Mcp/ColaFlow.Modules.Mcp.Tests/Infrastructure/Middleware/McpExceptionHandlerMiddlewareTests.cs
Yaojia Wang c00c909489 feat(backend): Implement Story 5.4 - MCP Error Handling & Logging
Implement comprehensive error handling and structured logging for MCP module.

**Exception Hierarchy**:
- Created McpException base class with JSON-RPC error mapping
- Implemented 8 specific exception types (Parse, InvalidRequest, MethodNotFound, etc.)
- Each exception maps to correct HTTP status code (401, 403, 404, 422, 400, 500)

**Middleware**:
- McpCorrelationIdMiddleware: Generates/extracts correlation ID for request tracking
- McpExceptionHandlerMiddleware: Global exception handler with JSON-RPC error responses
- McpLoggingMiddleware: Request/response logging with sensitive data sanitization

**Serilog Integration**:
- Configured structured logging with Console and File sinks
- Log rotation (daily, 30-day retention)
- Correlation ID enrichment in all log entries

**Features**:
- Correlation ID propagation across request chain
- Structured logging with TenantId, UserId, ApiKeyId
- Sensitive data sanitization (API keys, passwords)
- Performance metrics (request duration, slow request warnings)
- JSON-RPC 2.0 compliant error responses

**Testing**:
- 174 tests passing (all MCP module tests)
- Unit tests for all exception classes
- Unit tests for all middleware components
- 100% coverage of error mapping and HTTP status codes

**Files Added**:
- 9 exception classes in Domain/Exceptions/
- 3 middleware classes in Infrastructure/Middleware/
- 4 test files with comprehensive coverage

**Files Modified**:
- Program.cs: Serilog configuration
- McpServiceExtensions.cs: Middleware pipeline registration
- JsonRpcError.cs: Added parameterless constructor for deserialization
- MCP Infrastructure .csproj: Added Serilog package reference

**Verification**:
 All 174 MCP module tests passing
 Build successful with no errors
 Exception-to-HTTP-status mapping verified
 Correlation ID propagation tested
 Sensitive data sanitization verified

Story: docs/stories/sprint_5/story_5_4.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 21:08:12 +01:00

197 lines
6.8 KiB
C#

using ColaFlow.Modules.Mcp.Contracts.JsonRpc;
using ColaFlow.Modules.Mcp.Domain.Exceptions;
using ColaFlow.Modules.Mcp.Infrastructure.Middleware;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using NSubstitute;
using System.Text.Json;
namespace ColaFlow.Modules.Mcp.Tests.Infrastructure.Middleware;
/// <summary>
/// Unit tests for McpExceptionHandlerMiddleware
/// </summary>
public class McpExceptionHandlerMiddlewareTests
{
private readonly ILogger<McpExceptionHandlerMiddleware> _logger;
public McpExceptionHandlerMiddlewareTests()
{
_logger = Substitute.For<ILogger<McpExceptionHandlerMiddleware>>();
}
[Fact]
public async Task InvokeAsync_ShouldCallNextMiddleware_WhenNoExceptionThrown()
{
// Arrange
var context = new DefaultHttpContext();
var nextCalled = false;
RequestDelegate next = (HttpContext ctx) =>
{
nextCalled = true;
return Task.CompletedTask;
};
var middleware = new McpExceptionHandlerMiddleware(next, _logger);
// Act
await middleware.InvokeAsync(context);
// Assert
nextCalled.Should().BeTrue();
}
[Fact]
public async Task InvokeAsync_ShouldHandleMcpException_AndReturnJsonRpcErrorResponse()
{
// Arrange
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
context.Items["CorrelationId"] = "test-correlation-id";
context.Items["McpRequestId"] = "test-request-id";
var expectedException = new McpNotFoundException("Task", "task-123");
RequestDelegate next = (HttpContext ctx) =>
{
throw expectedException;
};
var middleware = new McpExceptionHandlerMiddleware(next, _logger);
// Act
await middleware.InvokeAsync(context);
// Assert
context.Response.StatusCode.Should().Be(404);
context.Response.ContentType.Should().Be("application/json");
context.Response.Body.Seek(0, SeekOrigin.Begin);
var responseBody = await new StreamReader(context.Response.Body).ReadToEndAsync();
var response = JsonSerializer.Deserialize<JsonRpcResponse>(responseBody, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
response.Should().NotBeNull();
response!.JsonRpc.Should().Be("2.0");
response.Error.Should().NotBeNull();
response.Error!.Code.Should().Be((int)JsonRpcErrorCode.NotFound);
response.Error.Message.Should().Be("Task not found: task-123");
response.Id.Should().NotBeNull();
response.Id!.ToString().Should().Be("test-request-id");
}
[Fact]
public async Task InvokeAsync_ShouldHandleUnexpectedException_AndReturnInternalError()
{
// Arrange
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
context.Items["CorrelationId"] = "test-correlation-id";
RequestDelegate next = (HttpContext ctx) =>
{
throw new InvalidOperationException("Unexpected error");
};
var middleware = new McpExceptionHandlerMiddleware(next, _logger);
// Act
await middleware.InvokeAsync(context);
// Assert
context.Response.StatusCode.Should().Be(500);
context.Response.ContentType.Should().Be("application/json");
context.Response.Body.Seek(0, SeekOrigin.Begin);
var responseBody = await new StreamReader(context.Response.Body).ReadToEndAsync();
var response = JsonSerializer.Deserialize<JsonRpcResponse>(responseBody, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
response.Should().NotBeNull();
response!.Error.Should().NotBeNull();
response.Error!.Code.Should().Be((int)JsonRpcErrorCode.InternalError);
response.Error.Message.Should().Be("Internal server error");
// Should NOT expose exception details
response.Error.Data.Should().BeNull();
}
[Theory]
[InlineData(typeof(McpUnauthorizedException), 401)]
[InlineData(typeof(McpForbiddenException), 403)]
[InlineData(typeof(McpNotFoundException), 404)]
[InlineData(typeof(McpValidationException), 422)]
[InlineData(typeof(McpParseException), 400)]
[InlineData(typeof(McpInvalidRequestException), 400)]
[InlineData(typeof(McpMethodNotFoundException), 404)]
[InlineData(typeof(McpInvalidParamsException), 400)]
public async Task InvokeAsync_ShouldMapExceptionToCorrectHttpStatusCode(Type exceptionType, int expectedStatusCode)
{
// Arrange
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
McpException exception = exceptionType.Name switch
{
nameof(McpUnauthorizedException) => new McpUnauthorizedException(),
nameof(McpForbiddenException) => new McpForbiddenException(),
nameof(McpNotFoundException) => new McpNotFoundException("Resource", "123"),
nameof(McpValidationException) => new McpValidationException(),
nameof(McpParseException) => new McpParseException(),
nameof(McpInvalidRequestException) => new McpInvalidRequestException(),
nameof(McpMethodNotFoundException) => new McpMethodNotFoundException("test"),
nameof(McpInvalidParamsException) => new McpInvalidParamsException(),
_ => throw new ArgumentException("Unknown exception type")
};
RequestDelegate next = (HttpContext ctx) =>
{
throw exception;
};
var middleware = new McpExceptionHandlerMiddleware(next, _logger);
// Act
await middleware.InvokeAsync(context);
// Assert
context.Response.StatusCode.Should().Be(expectedStatusCode);
}
[Fact]
public async Task InvokeAsync_ShouldLogErrorWithStructuredData()
{
// Arrange
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
context.Items["CorrelationId"] = "test-correlation-id";
context.Items["TenantId"] = "tenant-123";
context.Items["ApiKeyId"] = "key-456";
var exception = new McpValidationException("Test validation error");
RequestDelegate next = (HttpContext ctx) =>
{
throw exception;
};
var middleware = new McpExceptionHandlerMiddleware(next, _logger);
// Act
await middleware.InvokeAsync(context);
// Assert
_logger.Received(1).Log(
LogLevel.Error,
Arg.Any<EventId>(),
Arg.Is<object>(o => o.ToString()!.Contains("MCP Error")),
exception,
Arg.Any<Func<object, Exception?, string>>());
}
}