diff --git a/src/backend/Cps.CaseManagement.Api/Helpers/RequestValidator.cs b/src/backend/Cps.CaseManagement.Api/Helpers/RequestValidator.cs index e9452b85..9ac3aaad 100644 --- a/src/backend/Cps.CaseManagement.Api/Helpers/RequestValidator.cs +++ b/src/backend/Cps.CaseManagement.Api/Helpers/RequestValidator.cs @@ -7,9 +7,17 @@ namespace Cps.CaseManagement.Api.Helpers; using Cps.CaseManagement.Domain.Models; using FluentValidation; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; public class RequestValidator : IRequestValidator { + private readonly ILogger _logger; + + public RequestValidator(ILogger logger) + { + _logger = logger; + } + public async Task> GetJsonBody(HttpRequest request) where V : AbstractValidator, new() { @@ -23,7 +31,8 @@ public async Task> GetJsonBody(HttpRequest request) } catch (JsonException ex) { - return InvalidRequest($"Invalid JSON format: {ex.Message}"); + _logger.LogWarning(ex, "Request body contained invalid JSON."); + return InvalidRequest("The request body contains invalid JSON."); } if (requestObject == null) diff --git a/src/tests/Cps.CaseManagement.Api.Tests/Unit/Helpers/RequestValidatorTests.cs b/src/tests/Cps.CaseManagement.Api.Tests/Unit/Helpers/RequestValidatorTests.cs new file mode 100644 index 00000000..3f2935e3 --- /dev/null +++ b/src/tests/Cps.CaseManagement.Api.Tests/Unit/Helpers/RequestValidatorTests.cs @@ -0,0 +1,207 @@ +using System.Text; +using System.Text.Json; +using Cps.CaseManagement.Api.Helpers; +using FluentValidation; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Cps.CaseManagement.Api.Tests.Unit.Helpers; + +public class RequestValidatorTests +{ + private readonly Mock> _loggerMock; + private readonly RequestValidator _sut; + + public RequestValidatorTests() + { + _loggerMock = new Mock>(); + _sut = new RequestValidator(_loggerMock.Object); + } + + #region Invalid JSON tests + + [Fact] + public async Task GetJsonBody_InvalidJson_ReturnsGenericErrorMessage() + { + // Arrange + var request = CreateRequestWithBody("{ this is not valid json }"); + + // Act + var result = await _sut.GetJsonBody(request); + + // Assert + Assert.False(result.IsValid); + Assert.Single(result.ValidationErrors); + Assert.Equal("The request body contains invalid JSON.", result.ValidationErrors[0]); + } + [Fact] + public async Task GetJsonBody_InvalidJson_DoesNotExposeExceptionDetails() + { + // Arrange — malformed JSON that always throws JsonException + var request = CreateRequestWithBody("{\"id\": }"); + + // Act + var result = await _sut.GetJsonBody(request); + + // Assert + Assert.False(result.IsValid); + Assert.Single(result.ValidationErrors); + Assert.DoesNotContain("System", result.ValidationErrors[0]); + Assert.DoesNotContain("Int32", result.ValidationErrors[0]); + Assert.DoesNotContain("Path", result.ValidationErrors[0]); + Assert.DoesNotContain("LineNumber", result.ValidationErrors[0]); + Assert.Equal("The request body contains invalid JSON.", result.ValidationErrors[0]); + } + + [Fact] + public async Task GetJsonBody_InvalidJson_LogsWarningWithException() + { + // Arrange + var request = CreateRequestWithBody("not json at all"); + + // Act + await _sut.GetJsonBody(request); + + // Assert + _loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Request body contained invalid JSON")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task GetJsonBody_EmptyBody_ReturnsGenericErrorMessage() + { + // Arrange + var request = CreateRequestWithBody(""); + + // Act + var result = await _sut.GetJsonBody(request); + + // Assert + Assert.False(result.IsValid); + Assert.Single(result.ValidationErrors); + // Empty string deserializes to null, which hits the null check + Assert.Contains(result.ValidationErrors[0], new[] { + "The request body contains invalid JSON.", + "Deserialized object is null." + }); + } + + #endregion + + #region Null deserialization tests + + [Fact] + public async Task GetJsonBody_NullJsonLiteral_ReturnsNullError() + { + // Arrange — "null" is valid JSON but deserializes to null + var request = CreateRequestWithBody("null"); + + // Act + var result = await _sut.GetJsonBody(request); + + // Assert + Assert.False(result.IsValid); + Assert.Single(result.ValidationErrors); + Assert.Equal("Deserialized object is null.", result.ValidationErrors[0]); + } + + #endregion + + #region Valid JSON tests + + [Fact] + public async Task GetJsonBody_ValidJson_PassingValidation_ReturnsIsValidTrue() + { + // Arrange + var dto = new TestDto { Id = 1, Name = "Test" }; + var request = CreateRequestWithBody(JsonSerializer.Serialize(dto)); + + // Act + var result = await _sut.GetJsonBody(request); + + // Assert + Assert.True(result.IsValid); + Assert.Empty(result.ValidationErrors); + Assert.Equal(1, result.Value.Id); + Assert.Equal("Test", result.Value.Name); + } + + [Fact] + public async Task GetJsonBody_ValidJson_FailingValidation_ReturnsValidationErrors() + { + // Arrange — Name is required by TestDtoValidator + var dto = new TestDto { Id = 1, Name = "" }; + var request = CreateRequestWithBody(JsonSerializer.Serialize(dto)); + + // Act + var result = await _sut.GetJsonBody(request); + + // Assert + Assert.False(result.IsValid); + Assert.NotEmpty(result.ValidationErrors); + Assert.Contains("Name is required.", result.ValidationErrors); + } + + [Fact] + public async Task GetJsonBody_ValidJson_DoesNotLogWarning() + { + // Arrange + var dto = new TestDto { Id = 1, Name = "Test" }; + var request = CreateRequestWithBody(JsonSerializer.Serialize(dto)); + + // Act + await _sut.GetJsonBody(request); + + // Assert — should not log any warning for valid JSON + _loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), + Times.Never); + } + + #endregion + + #region Helpers + + private static HttpRequest CreateRequestWithBody(string body) + { + var context = new DefaultHttpContext(); + var request = context.Request; + request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body)); + request.ContentType = "application/json"; + return request; + } + + #endregion + + #region Test DTOs and Validators + + private class TestDto + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + private class TestDtoValidator : AbstractValidator + { + public TestDtoValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Name is required."); + } + } + + #endregion +} \ No newline at end of file