feat(#22): manage landlord profile from OIDC

This commit is contained in:
nessar committed 2026-08-17 14:18:15 +02:00
1 parent f02e8a9ba5
commit ecacab0b9f
16 files changed
+661 -3

No files matched your search

@@ -0,0 +1,9 @@
db.getSiblingDB('lmnp').landlords.createIndex(
{ oidcSubject: 1 },
{ name: 'landlords_oidc_subject_unique', unique: true, sparse: true },
);
db.getSiblingDB('lmnp').landlords.createIndex(
{ email: 1 },
{ name: 'landlords_email_unique', unique: true, sparse: true },
);
@@ -23,6 +23,7 @@ public class SecurityConfig {
.requestMatchers("/api/tenants", "/api/tenants/**").authenticated()
.requestMatchers("/api/leases", "/api/leases/**").authenticated()
.requestMatchers("/api/rent-payments", "/api/rent-payments/**").authenticated()
.requestMatchers("/api/landlord-profile").authenticated()
.requestMatchers("/api/user").permitAll()
.anyRequest().permitAll()
)
@@ -1,6 +1,7 @@
package fr.nessar.lmnporganizer.backend.controller;
import fr.nessar.lmnporganizer.backend.exception.AttachmentTooLargeException;
import fr.nessar.lmnporganizer.backend.exception.EmailAlreadyUsedException;
import fr.nessar.lmnporganizer.backend.exception.InvalidPhoneNumberException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -26,6 +27,11 @@ public class ApiExceptionHandler {
return error(HttpStatus.BAD_REQUEST, exception.getMessage());
}
@ExceptionHandler(EmailAlreadyUsedException.class)
public ResponseEntity<ApiError> handleDuplicateEmail(EmailAlreadyUsedException exception) {
return error(HttpStatus.CONFLICT, exception.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleInvalidRequest(MethodArgumentNotValidException exception) {
String message = exception.getBindingResult().getFieldErrors().stream()
@@ -0,0 +1,34 @@
package fr.nessar.lmnporganizer.backend.controller;
import fr.nessar.lmnporganizer.backend.dto.LandlordProfileRequest;
import fr.nessar.lmnporganizer.backend.dto.LandlordProfileResponse;
import fr.nessar.lmnporganizer.backend.service.LandlordProfileService;
import jakarta.validation.Valid;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/landlord-profile")
public class LandlordProfileController {
private final LandlordProfileService landlordProfileService;
public LandlordProfileController(LandlordProfileService landlordProfileService) {
this.landlordProfileService = landlordProfileService;
}
@GetMapping
public LandlordProfileResponse getProfile(@AuthenticationPrincipal OidcUser principal) {
return landlordProfileService.getProfile(principal);
}
@PutMapping
public LandlordProfileResponse updateProfile(@AuthenticationPrincipal OidcUser principal,
@Valid @RequestBody LandlordProfileRequest request) {
return landlordProfileService.updateProfile(principal, request);
}
}
@@ -0,0 +1,13 @@
package fr.nessar.lmnporganizer.backend.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record LandlordProfileRequest(
@NotBlank(message = "Le prénom est obligatoire.") String firstName,
@NotBlank(message = "Le nom est obligatoire.") String lastName,
@NotBlank(message = "L'adresse e-mail est obligatoire.") @Email(message = "L'adresse e-mail est invalide.") String email,
@NotBlank(message = "Le numéro de téléphone est obligatoire.") String phone,
@NotBlank(message = "L'adresse postale est obligatoire.") String address
) {
}
@@ -0,0 +1,4 @@
package fr.nessar.lmnporganizer.backend.dto;
public record LandlordProfileResponse(String firstName, String lastName, String email, String phone, String address) {
}
@@ -0,0 +1,7 @@
package fr.nessar.lmnporganizer.backend.exception;
public class EmailAlreadyUsedException extends RuntimeException {
public EmailAlreadyUsedException() {
super("Cette adresse e-mail est déjà utilisée.");
}
}
@@ -7,6 +7,7 @@ import org.springframework.data.mongodb.core.mapping.Document;
public class Landlord {
@Id
private String id;
private String oidcSubject;
private String firstName;
private String lastName;
private String email;
@@ -24,6 +25,12 @@ public class Landlord {
this.address = address;
}
public Landlord(String id, String oidcSubject, String firstName, String lastName, String email, String phone,
String address) {
this(id, firstName, lastName, email, phone, address);
this.oidcSubject = oidcSubject;
}
public String getId() {
return id;
}
@@ -32,6 +39,14 @@ public class Landlord {
this.id = id;
}
public String getOidcSubject() {
return oidcSubject;
}
public void setOidcSubject(String oidcSubject) {
this.oidcSubject = oidcSubject;
}
public String getFirstName() {
return firstName;
}
@@ -4,6 +4,11 @@ import fr.nessar.lmnporganizer.backend.model.Landlord;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface LandlordRepository extends MongoRepository<Landlord, String> {
Optional<Landlord> findByOidcSubject(String oidcSubject);
Optional<Landlord> findByEmail(String email);
}
@@ -0,0 +1,67 @@
package fr.nessar.lmnporganizer.backend.service;
import fr.nessar.lmnporganizer.backend.dto.LandlordProfileRequest;
import fr.nessar.lmnporganizer.backend.dto.LandlordProfileResponse;
import fr.nessar.lmnporganizer.backend.exception.EmailAlreadyUsedException;
import fr.nessar.lmnporganizer.backend.model.Landlord;
import fr.nessar.lmnporganizer.backend.repository.LandlordRepository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.stereotype.Service;
@Service
public class LandlordProfileService {
private final LandlordRepository landlordRepository;
private final PhoneNumberNormalizer phoneNumberNormalizer;
public LandlordProfileService(LandlordRepository landlordRepository, PhoneNumberNormalizer phoneNumberNormalizer) {
this.landlordRepository = landlordRepository;
this.phoneNumberNormalizer = phoneNumberNormalizer;
}
public LandlordProfileResponse getProfile(OidcUser principal) {
Landlord landlord = landlordRepository.findByOidcSubject(principal.getSubject()).orElseGet(() ->
new Landlord(null, principal.getSubject(), claim(principal, "given_name"),
claim(principal, "family_name"), principal.getClaimAsString("email"), "", ""));
return response(landlord);
}
public LandlordProfileResponse updateProfile(OidcUser principal, LandlordProfileRequest request) {
Landlord landlord = landlordRepository.findByOidcSubject(principal.getSubject())
.orElseGet(() -> new Landlord(null, principal.getSubject(), "", "", "", "", ""));
String email = request.email().trim();
landlordRepository.findByEmail(email)
.filter(existing -> !existing.getId().equals(landlord.getId()))
.ifPresent(existing -> {
throw new EmailAlreadyUsedException();
});
landlord.setFirstName(request.firstName().trim());
landlord.setLastName(request.lastName().trim());
landlord.setEmail(email);
landlord.setPhone(phoneNumberNormalizer.normalize(request.phone()));
landlord.setAddress(request.address().trim());
return response(save(landlord));
}
private Landlord save(Landlord landlord) {
try {
return landlordRepository.save(landlord);
} catch (DuplicateKeyException exception) {
throw new EmailAlreadyUsedException();
}
}
private String claim(OidcUser principal, String name) {
String value = principal.getClaimAsString(name);
return value == null ? "" : value;
}
private LandlordProfileResponse response(Landlord landlord) {
return new LandlordProfileResponse(value(landlord.getFirstName()), value(landlord.getLastName()),
value(landlord.getEmail()), value(landlord.getPhone()), value(landlord.getAddress()));
}
private String value(String value) {
return value == null ? "" : value;
}
}
@@ -0,0 +1,119 @@
package fr.nessar.lmnporganizer.backend.controller;
import fr.nessar.lmnporganizer.backend.model.Landlord;
import fr.nessar.lmnporganizer.backend.repository.LandlordRepository;
import fr.nessar.lmnporganizer.backend.SecurityConfig;
import fr.nessar.lmnporganizer.backend.service.LandlordProfileService;
import fr.nessar.lmnporganizer.backend.service.PhoneNumberNormalizer;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(LandlordProfileController.class)
@Import({ApiExceptionHandler.class, LandlordProfileService.class, PhoneNumberNormalizer.class, SecurityConfig.class})
class LandlordProfileControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private LandlordRepository landlordRepository;
@Test
void whenFirstProfileRead_thenOidcClaimsPrefillTheNewProfile() throws Exception {
when(landlordRepository.findByOidcSubject("subject-1")).thenReturn(Optional.empty());
mockMvc.perform(get("/api/landlord-profile")
.with(oidcLogin().idToken(token -> token
.claim("sub", "subject-1")
.claim("given_name", "Camille")
.claim("family_name", "Martin")
.claim("email", "camille@example.com"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("Camille"))
.andExpect(jsonPath("$.lastName").value("Martin"))
.andExpect(jsonPath("$.email").value("camille@example.com"));
verify(landlordRepository).findByOidcSubject("subject-1");
verify(landlordRepository, never()).save(any(Landlord.class));
}
@Test
void whenProfileRequestIsIncomplete_thenReturnsHelpfulValidationError() throws Exception {
mockMvc.perform(put("/api/landlord-profile")
.with(oidcLogin().idToken(token -> token.claim("sub", "subject-1")))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"\",\"lastName\":\"Martin\",\"email\":\"camille@example.com\",\"phone\":\"06 00 00 00 00\",\"address\":\"1 rue de Paris\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("Le prénom est obligatoire."));
}
@Test
void whenProfileIsUpdated_thenOnlyTheConnectedAccountsProfileChanges() throws Exception {
Landlord profile = new Landlord("profile-1", "subject-1", "Camille", "Martin", "camille@example.com", "+33600000000", "1 rue de Paris");
Landlord otherProfile = new Landlord("profile-2", "subject-2", "Alex", "Durand", "alex@example.com", "+33600000001", "2 rue de Paris");
when(landlordRepository.findByOidcSubject("subject-1")).thenReturn(Optional.of(profile));
when(landlordRepository.findByOidcSubject("subject-2")).thenReturn(Optional.of(otherProfile));
when(landlordRepository.findByEmail("camille.renee@example.com")).thenReturn(Optional.empty());
when(landlordRepository.save(any(Landlord.class))).thenAnswer(invocation -> invocation.getArgument(0));
mockMvc.perform(put("/api/landlord-profile")
.with(oidcLogin().idToken(token -> token.claim("sub", "subject-1")))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Camille\",\"lastName\":\"Renée\",\"email\":\"camille.renee@example.com\",\"phone\":\"06 00 00 00 00\",\"address\":\"2 rue de Lyon\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.lastName").value("Renée"))
.andExpect(jsonPath("$.email").value("camille.renee@example.com"))
.andExpect(jsonPath("$.phone").value("+33600000000"))
.andExpect(jsonPath("$.address").value("2 rue de Lyon"));
mockMvc.perform(get("/api/landlord-profile")
.with(oidcLogin().idToken(token -> token.claim("sub", "subject-2"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("Alex"))
.andExpect(jsonPath("$.lastName").value("Durand"))
.andExpect(jsonPath("$.email").value("alex@example.com"));
verify(landlordRepository).findByOidcSubject("subject-1");
verify(landlordRepository).findByOidcSubject("subject-2");
verify(landlordRepository, never()).findById(anyString());
}
@Test
void whenAnotherAccountUsesTheEmail_thenProfileUpdateIsRejected() throws Exception {
Landlord profile = new Landlord("profile-1", "subject-1", "Camille", "Martin", "camille@example.com", "+33600000000", "1 rue de Paris");
Landlord otherProfile = new Landlord("profile-2", "subject-2", "Alex", "Durand", "alex@example.com", "+33600000001", "2 rue de Paris");
when(landlordRepository.findByOidcSubject("subject-1")).thenReturn(Optional.of(profile));
when(landlordRepository.findByEmail("alex@example.com")).thenReturn(Optional.of(otherProfile));
mockMvc.perform(put("/api/landlord-profile")
.with(oidcLogin().idToken(token -> token.claim("sub", "subject-1")))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Camille\",\"lastName\":\"Martin\",\"email\":\"alex@example.com\",\"phone\":\"06 00 00 00 00\",\"address\":\"1 rue de Paris\"}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.message").value("Cette adresse e-mail est déjà utilisée."));
}
@Test
void whenUnauthenticated_thenProfileCannotBeRead() throws Exception {
mockMvc.perform(get("/api/landlord-profile"))
.andExpect(status().isUnauthorized());
}
}