feat(#22): manage landlord profile from OIDC
This commit is contained in:
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()
|
||||
)
|
||||
|
||||
+6
@@ -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()
|
||||
|
||||
+34
@@ -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) {
|
||||
}
|
||||
+7
@@ -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;
|
||||
}
|
||||
|
||||
+5
@@ -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);
|
||||
}
|
||||
+67
@@ -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;
|
||||
}
|
||||
}
|
||||
+119
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user