fix: validate tenant and lease contact details

This commit is contained in:
nessar committed 2026-08-17 14:18:14 +02:00
1 parent 8603b98714
commit bf6d8e6bb6
27 files changed
+444 -54

No files matched your search

@@ -0,0 +1,44 @@
package fr.nessar.lmnporganizer.backend.controller;
import fr.nessar.lmnporganizer.backend.exception.AttachmentTooLargeException;
import fr.nessar.lmnporganizer.backend.exception.InvalidPhoneNumberException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(AttachmentTooLargeException.class)
public ResponseEntity<ApiError> handleAttachmentTooLarge(AttachmentTooLargeException exception) {
return error(HttpStatus.PAYLOAD_TOO_LARGE, exception.getMessage());
}
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<ApiError> handleMultipartTooLarge() {
return error(HttpStatus.PAYLOAD_TOO_LARGE, "Une pièce jointe ne peut pas dépasser 250 Mo.");
}
@ExceptionHandler(InvalidPhoneNumberException.class)
public ResponseEntity<ApiError> handleInvalidPhone(InvalidPhoneNumberException exception) {
return error(HttpStatus.BAD_REQUEST, exception.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleInvalidRequest(MethodArgumentNotValidException exception) {
String message = exception.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(error -> error.getDefaultMessage())
.orElse("La requête est invalide.");
return error(HttpStatus.BAD_REQUEST, message);
}
private ResponseEntity<ApiError> error(HttpStatus status, String message) {
return ResponseEntity.status(status).body(new ApiError(message));
}
private record ApiError(String message) {
}
}
@@ -7,6 +7,8 @@ import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository;
import fr.nessar.lmnporganizer.backend.repository.LeaseRepository;
import fr.nessar.lmnporganizer.backend.repository.TenantRepository;
import fr.nessar.lmnporganizer.backend.service.LeasePdfService;
import fr.nessar.lmnporganizer.backend.service.PhoneNumberNormalizer;
import jakarta.validation.Valid;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
@@ -25,13 +27,16 @@ public class LeaseController {
private final ApartmentRepository apartmentRepository;
private final TenantRepository tenantRepository;
private final LeasePdfService leasePdfService;
private final PhoneNumberNormalizer phoneNumberNormalizer;
public LeaseController(LeaseRepository leaseRepository, ApartmentRepository apartmentRepository,
TenantRepository tenantRepository, LeasePdfService leasePdfService) {
TenantRepository tenantRepository, LeasePdfService leasePdfService,
PhoneNumberNormalizer phoneNumberNormalizer) {
this.leaseRepository = leaseRepository;
this.apartmentRepository = apartmentRepository;
this.tenantRepository = tenantRepository;
this.leasePdfService = leasePdfService;
this.phoneNumberNormalizer = phoneNumberNormalizer;
}
@GetMapping
@@ -45,17 +50,18 @@ public class LeaseController {
}
@PostMapping
public ResponseEntity<Lease> createLease(@RequestBody Lease lease) {
public ResponseEntity<Lease> createLease(@Valid @RequestBody Lease lease) {
if (!referencesExist(lease)) {
return ResponseEntity.badRequest().build();
}
lease.setId(null);
lease.setLandlordId("default-landlord");
normalizeGuarantorPhones(lease);
return ResponseEntity.status(HttpStatus.CREATED).body(leaseRepository.save(lease));
}
@PutMapping("/{id}")
public ResponseEntity<Lease> updateLease(@PathVariable String id, @RequestBody Lease details) {
public ResponseEntity<Lease> updateLease(@PathVariable String id, @Valid @RequestBody Lease details) {
if (!referencesExist(details)) {
return ResponseEntity.badRequest().build();
}
@@ -111,6 +117,7 @@ public class LeaseController {
target.setApartmentId(source.getApartmentId());
target.setTenantIds(source.getTenantIds());
target.setGuarantors(source.getGuarantors());
normalizeGuarantorPhones(target);
target.setStartDate(source.getStartDate());
target.setEndDate(source.getEndDate());
target.setMonthlyRent(source.getMonthlyRent());
@@ -118,4 +125,8 @@ public class LeaseController {
target.setSecurityDeposit(source.getSecurityDeposit());
target.setStatus(source.getStatus());
}
private void normalizeGuarantorPhones(Lease lease) {
lease.getGuarantors().forEach(guarantor -> guarantor.setPhone(phoneNumberNormalizer.normalize(guarantor.getPhone())));
}
}
@@ -4,6 +4,9 @@ import fr.nessar.lmnporganizer.backend.model.Tenant;
import fr.nessar.lmnporganizer.backend.model.TenantAttachment;
import fr.nessar.lmnporganizer.backend.repository.TenantRepository;
import fr.nessar.lmnporganizer.backend.service.TenantAttachmentStorage;
import fr.nessar.lmnporganizer.backend.service.AttachmentUploadPolicy;
import fr.nessar.lmnporganizer.backend.service.PhoneNumberNormalizer;
import jakarta.validation.Valid;
import org.springframework.core.io.Resource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
@@ -21,10 +24,15 @@ import java.util.List;
public class TenantController {
private final TenantRepository tenantRepository;
private final TenantAttachmentStorage attachmentStorage;
private final AttachmentUploadPolicy attachmentUploadPolicy;
private final PhoneNumberNormalizer phoneNumberNormalizer;
public TenantController(TenantRepository tenantRepository, TenantAttachmentStorage attachmentStorage) {
public TenantController(TenantRepository tenantRepository, TenantAttachmentStorage attachmentStorage,
AttachmentUploadPolicy attachmentUploadPolicy, PhoneNumberNormalizer phoneNumberNormalizer) {
this.tenantRepository = tenantRepository;
this.attachmentStorage = attachmentStorage;
this.attachmentUploadPolicy = attachmentUploadPolicy;
this.phoneNumberNormalizer = phoneNumberNormalizer;
}
@GetMapping
@@ -38,19 +46,20 @@ public class TenantController {
}
@PostMapping
public ResponseEntity<Tenant> createTenant(@RequestBody Tenant tenant) {
public ResponseEntity<Tenant> createTenant(@Valid @RequestBody Tenant tenant) {
tenant.setId(null);
tenant.setLandlordId("default-landlord");
tenant.setPhone(phoneNumberNormalizer.normalize(tenant.getPhone()));
return ResponseEntity.status(HttpStatus.CREATED).body(tenantRepository.save(tenant));
}
@PutMapping("/{id}")
public ResponseEntity<Tenant> updateTenant(@PathVariable String id, @RequestBody Tenant details) {
public ResponseEntity<Tenant> updateTenant(@PathVariable String id, @Valid @RequestBody Tenant details) {
return tenantRepository.findById(id).map(tenant -> {
tenant.setFirstName(details.getFirstName());
tenant.setLastName(details.getLastName());
tenant.setEmail(details.getEmail());
tenant.setPhone(details.getPhone());
tenant.setPhone(phoneNumberNormalizer.normalize(details.getPhone()));
tenant.setAddress(details.getAddress());
tenant.setLandlordId("default-landlord");
return ResponseEntity.ok(tenantRepository.save(tenant));
@@ -73,6 +82,7 @@ public class TenantController {
if (tenant == null) {
return ResponseEntity.notFound().build();
}
attachmentUploadPolicy.validate(file);
TenantAttachment attachment = attachmentStorage.store(tenantId, file);
tenant.getAttachments().add(attachment);
tenantRepository.save(tenant);
@@ -0,0 +1,7 @@
package fr.nessar.lmnporganizer.backend.exception;
public class AttachmentTooLargeException extends RuntimeException {
public AttachmentTooLargeException(String message) {
super(message);
}
}
@@ -0,0 +1,7 @@
package fr.nessar.lmnporganizer.backend.exception;
public class InvalidPhoneNumberException extends RuntimeException {
public InvalidPhoneNumberException() {
super("Le numéro de téléphone doit être français ou international.");
}
}
@@ -1,9 +1,18 @@
package fr.nessar.lmnporganizer.backend.model;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public class Guarantor {
private String id;
@NotBlank(message = "Le prénom du garant est obligatoire.")
private String firstName;
@NotBlank(message = "Le nom du garant est obligatoire.")
private String lastName;
@NotBlank(message = "L'adresse e-mail du garant est obligatoire.")
@Email(message = "L'adresse e-mail du garant est invalide.")
private String email;
private String phone;
private String address;
@@ -1,5 +1,6 @@
package fr.nessar.lmnporganizer.backend.model;
import jakarta.validation.Valid;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
@@ -23,7 +24,7 @@ public class Lease {
@Indexed
private List<String> tenantIds = new ArrayList<>();
private List<Guarantor> guarantors = new ArrayList<>();
private List<@Valid Guarantor> guarantors = new ArrayList<>();
private LocalDate startDate;
private LocalDate endDate;
private BigDecimal monthlyRent;
@@ -1,5 +1,7 @@
package fr.nessar.lmnporganizer.backend.model;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
@@ -15,8 +17,14 @@ public class Tenant {
@Indexed
private String landlordId;
@NotBlank(message = "Le prénom est obligatoire.")
private String firstName;
@NotBlank(message = "Le nom est obligatoire.")
private String lastName;
@NotBlank(message = "L'adresse e-mail est obligatoire.")
@Email(message = "L'adresse e-mail est invalide.")
private String email;
private String phone;
private String address;
@@ -0,0 +1,22 @@
package fr.nessar.lmnporganizer.backend.service;
import fr.nessar.lmnporganizer.backend.exception.AttachmentTooLargeException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.unit.DataSize;
import org.springframework.web.multipart.MultipartFile;
@Service
public class AttachmentUploadPolicy {
private final DataSize maximumSize;
public AttachmentUploadPolicy(@Value("${lmnp.attachments.max-size:250MB}") String maximumSize) {
this.maximumSize = DataSize.parse(maximumSize);
}
public void validate(MultipartFile attachment) {
if (attachment.getSize() > maximumSize.toBytes()) {
throw new AttachmentTooLargeException("Une pièce jointe ne peut pas dépasser 250 Mo.");
}
}
}
@@ -0,0 +1,25 @@
package fr.nessar.lmnporganizer.backend.service;
import fr.nessar.lmnporganizer.backend.exception.InvalidPhoneNumberException;
import org.springframework.stereotype.Service;
@Service
public class PhoneNumberNormalizer {
public String normalize(String phone) {
if (phone == null || phone.isBlank()) {
return "";
}
String compact = phone.trim().replaceAll("[\\s()./-]", "");
if (compact.startsWith("00")) {
compact = "+" + compact.substring(2);
}
if (compact.matches("0\\d{9}")) {
return "+33" + compact.substring(1);
}
if (compact.matches("\\+\\d{8,15}")) {
return compact;
}
throw new InvalidPhoneNumberException();
}
}
@@ -1,6 +1,9 @@
spring.application.name=backend
spring.mongodb.uri=${SPRING_DATA_MONGODB_URI:mongodb://localhost:27017/lmnp}
lmnp.storage.attachments=${LMNP_ATTACHMENT_STORAGE_PATH:${user.home}/.lmnp-organizer/attachments}
lmnp.attachments.max-size=${LMNP_ATTACHMENT_MAX_SIZE:250MB}
spring.servlet.multipart.max-file-size=${lmnp.attachments.max-size}
spring.servlet.multipart.max-request-size=251MB
# OAuth2 Client registration
spring.security.oauth2.client.registration.oidc.client-id=${OIDC_CLIENT_ID:lmnp-client}
@@ -61,7 +61,18 @@ class LeaseControllerTest {
.andExpect(jsonPath("$.landlordId").value("default-landlord"))
.andExpect(jsonPath("$.apartmentId").value("apartment-1"))
.andExpect(jsonPath("$.tenantIds[0]").value("tenant-1"))
.andExpect(jsonPath("$.guarantors[0].lastName").value("Durand"));
.andExpect(jsonPath("$.guarantors[0].lastName").value("Durand"))
.andExpect(jsonPath("$.guarantors[0].phone").value("+33600000000"));
}
@Test
void whenCreateLeaseWithInvalidGuarantorEmail_thenBadRequest() throws Exception {
mockMvc.perform(post("/api/leases")
.with(oidcLogin())
.contentType(MediaType.APPLICATION_JSON)
.content(leaseJson().replace("alex@example.com", "alex-at-example.com")))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("L'adresse e-mail du garant est invalide."));
}
@Test
@@ -161,7 +172,7 @@ class LeaseControllerTest {
"firstName": "Alex",
"lastName": "Durand",
"email": "alex@example.com",
"phone": "+33600000000",
"phone": "06 00 00 00 00",
"address": "5 rue Victor Hugo"
}],
"startDate": "2026-09-01",
@@ -61,6 +61,48 @@ class TenantControllerTest {
.andExpect(jsonPath("$.landlordId").value("default-landlord"));
}
@Test
void whenCreateTenantWithFrenchPhone_thenPhoneIsNormalized() throws Exception {
when(tenantRepository.save(any(Tenant.class))).thenAnswer(invocation -> {
Tenant tenant = invocation.getArgument(0);
tenant.setId("tenant-1");
return tenant;
});
mockMvc.perform(post("/api/tenants")
.with(oidcLogin())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"firstName": "Camille",
"lastName": "Martin",
"email": "camille@example.com",
"phone": "06 12 34 56 78",
"address": "Lyon"
}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.phone").value("+33612345678"));
}
@Test
void whenCreateTenantWithInvalidEmail_thenBadRequest() throws Exception {
mockMvc.perform(post("/api/tenants")
.with(oidcLogin())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"firstName": "Camille",
"lastName": "Martin",
"email": "not-an-email",
"phone": "",
"address": "Lyon"
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("L'adresse e-mail est invalide."));
}
@Test
void whenGetTenantsWithoutAuthentication_thenUnauthorized() throws Exception {
mockMvc.perform(get("/api/tenants"))
@@ -0,0 +1,31 @@
package fr.nessar.lmnporganizer.backend.service;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import fr.nessar.lmnporganizer.backend.exception.AttachmentTooLargeException;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
class AttachmentUploadPolicyTest {
@Test
void rejectsAttachmentsLargerThanConfiguredLimit() {
AttachmentUploadPolicy policy = new AttachmentUploadPolicy("250MB");
MultipartFile attachment = mock(MultipartFile.class);
when(attachment.getSize()).thenReturn(250L * 1024 * 1024 + 1);
assertThatThrownBy(() -> policy.validate(attachment))
.isInstanceOf(AttachmentTooLargeException.class);
}
@Test
void acceptsAttachmentsAtConfiguredLimit() {
AttachmentUploadPolicy policy = new AttachmentUploadPolicy("250MB");
MockMultipartFile attachment = new MockMultipartFile("attachment", "lease.pdf", "application/pdf", new byte[0]);
policy.validate(attachment);
}
}
@@ -11,3 +11,4 @@ spring.security.oauth2.client.provider.oidc.user-info-uri=http://localhost:8090/
spring.security.oauth2.client.provider.oidc.jwk-set-uri=http://localhost:8090/realms/test/protocol/openid-connect/certs
spring.security.oauth2.client.provider.oidc.user-name-attribute=preferred_username
lmnp.storage.attachments=${java.io.tmpdir}/lmnp-organizer-test/attachments
lmnp.attachments.max-size=250MB