fix: validate tenant and lease contact details
This commit is contained in:
1 parent
8603b98714
commit
bf6d8e6bb6
27 files changed
+444
-54
No files matched your search
+44
@@ -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) {
|
||||
}
|
||||
}
|
||||
+14
-3
@@ -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())));
|
||||
}
|
||||
}
|
||||
+14
-4
@@ -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);
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package fr.nessar.lmnporganizer.backend.exception;
|
||||
|
||||
public class AttachmentTooLargeException extends RuntimeException {
|
||||
public AttachmentTooLargeException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
+7
@@ -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;
|
||||
|
||||
+22
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -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}
|
||||
|
||||
+13
-2
@@ -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",
|
||||
|
||||
+42
@@ -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"))
|
||||
|
||||
+31
@@ -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
|
||||
Reference in new issue
Block a user