Harden validation error recovery

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent d16239881a
commit ce53bab698
11 files changed
+130 -7

No files matched your search

@@ -19,10 +19,14 @@ import org.slf4j.LoggerFactory;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
@RestControllerAdvice(basePackages = "fr.nessar.lmnporganizer.rental.adapter.in.web")
public class RentalApiExceptionHandler {
@@ -93,6 +97,27 @@ ResponseEntity<ValidationApiError> invalidRequest(MethodArgumentNotValidExceptio
.toList()));
}
@ExceptionHandler(HttpMessageNotReadableException.class)
ResponseEntity<ValidationApiError> unreadableRequest() {
return validation("request", "malformed");
}
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
ResponseEntity<ValidationApiError> invalidParameter(MethodArgumentTypeMismatchException exception) {
return validation(exception.getName(), "invalid");
}
@ExceptionHandler(MissingServletRequestParameterException.class)
ResponseEntity<ValidationApiError> missingParameter(
MissingServletRequestParameterException exception) {
return validation(exception.getParameterName(), "required");
}
@ExceptionHandler(MissingServletRequestPartException.class)
ResponseEntity<ValidationApiError> missingPart(MissingServletRequestPartException exception) {
return validation(exception.getRequestPartName(), "required");
}
@ExceptionHandler(Exception.class)
ResponseEntity<ApiError> unexpected(Exception exception) {
LOGGER.error("Unhandled rental API exception", exception);
@@ -107,6 +132,13 @@ private static ResponseEntity<ApiError> error(HttpStatus status, String message)
return ResponseEntity.status(status).body(new ApiError(message));
}
private static ResponseEntity<ValidationApiError> validation(String field, String code) {
return ResponseEntity.badRequest()
.body(
new ValidationApiError(
"validation_failed", java.util.List.of(new FieldValidationError(field, code))));
}
record ApiError(String message) {}
record CodedApiError(String code, String message) {}
@@ -2,6 +2,7 @@ package fr.nessar.lmnporganizer.rental.adapter.in.web;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
@@ -31,6 +32,7 @@ import fr.nessar.lmnporganizer.rental.application.apartment.ApartmentCatalogServ
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
import fr.nessar.lmnporganizer.rental.application.lease.AmendLeaseService;
import fr.nessar.lmnporganizer.rental.application.lease.GenerateLeaseDocumentService;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseClauseCatalog;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseDraftService;
import fr.nessar.lmnporganizer.rental.application.rent.AllocateRentPaymentService;
import fr.nessar.lmnporganizer.rental.application.rent.GeneratePaymentReceiptService;
@@ -126,6 +128,7 @@ private static final RentPaymentRefundId REFUND_ID = new RentPaymentRefundId("re
@MockitoBean private LeaseDraftService leaseDrafts;
@MockitoBean private AmendLeaseService amendments;
@MockitoBean private GenerateLeaseDocumentService leaseDocuments;
@MockitoBean private LeaseClauseCatalog leaseClauseCatalog;
@MockitoBean private RentPaymentHistoryService paymentHistory;
@MockitoBean private RecordRentPaymentService paymentRecording;
@MockitoBean private AllocateRentPaymentService paymentAllocation;
@@ -136,9 +139,17 @@ private static final RentPaymentRefundId REFUND_ID = new RentPaymentRefundId("re
@BeforeEach
void authenticateLandlordAndFixTime() {
Instant now = Instant.parse("2026-03-01T12:00:00Z");
when(accounts.identify(any())).thenReturn(landlord());
when(clock.instant()).thenReturn(Instant.parse("2026-03-01T12:00:00Z"));
when(clock.instant()).thenReturn(now);
when(clock.getZone()).thenReturn(ZoneOffset.UTC);
when(clock.withZone(any())).thenReturn(Clock.fixed(now, ZoneOffset.UTC));
LeaseClauseCatalog clauses =
LeaseClauseCatalog.load(getClass().getResourceAsStream("/lease-clauses.yml"));
when(leaseClauseCatalog.available(anyString(), any()))
.thenAnswer(
invocation ->
clauses.available(invocation.getArgument(0), invocation.getArgument(1)));
}
@Test
@@ -161,6 +172,25 @@ void protectedRentalMutationsRejectMissingCsrfTokens() throws Exception {
.andExpect(status().isForbidden());
}
@Test
void malformedJsonIsAStableClientValidationFailure() throws Exception {
mockMvc
.perform(
post("/api/apartments")
.with(asLandlord())
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(
"""
{"name":"Studio","address":"12 rue A","purchasePrice":"not-a-number",
"description":"","privateAreas":[]}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("validation_failed"))
.andExpect(jsonPath("$.errors[0].field").value("request"))
.andExpect(jsonPath("$.errors[0].code").value("malformed"));
}
@Test
void oidcClaimsScopeApartmentCreationAndLookupAndResponsesAreMapped() throws Exception {
Apartment apartment =
@@ -413,7 +443,11 @@ void leaseDraftRequestsRejectAnImpossibleAnnualRevisionDay() throws Exception {
"referenceYear":2026,"seriesId":"001515333"}}}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("Le jour annuel de révision IRL est invalide."));
.andExpect(jsonPath("$.code").value("validation_failed"))
.andExpect(
jsonPath("$.errors[0].field")
.value("legalTerms.rentRevision.annualRevisionDay"))
.andExpect(jsonPath("$.errors[0].code").value("invalid_date"));
}
@Test
@@ -16,6 +16,7 @@ import fr.nessar.lmnporganizer.rental.adapter.in.web.common.RentalApiExceptionHa
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
import fr.nessar.lmnporganizer.rental.application.lease.AmendLeaseService;
import fr.nessar.lmnporganizer.rental.application.lease.GenerateLeaseDocumentService;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseClauseCatalog;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseDraftService;
import fr.nessar.lmnporganizer.rental.domain.landlord.ExternalIdentity;
import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
@@ -43,6 +44,7 @@ class LeaseAmendmentWebTest {
@MockitoBean LeaseDraftService drafts;
@MockitoBean AmendLeaseService amendments;
@MockitoBean GenerateLeaseDocumentService documents;
@MockitoBean LeaseClauseCatalog clauseCatalog;
@MockitoBean Clock clock;
@BeforeEach