diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/HelloController.java b/services/spring-member/src/main/java/tum/devoops/memberservice/controller/HelloController.java similarity index 92% rename from services/spring-member/src/main/java/tum/devoops/memberservice/HelloController.java rename to services/spring-member/src/main/java/tum/devoops/memberservice/controller/HelloController.java index c88392b..c0715a3 100644 --- a/services/spring-member/src/main/java/tum/devoops/memberservice/HelloController.java +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/controller/HelloController.java @@ -1,4 +1,4 @@ -package tum.devoops.memberservice; +package tum.devoops.memberservice.controller; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/controller/MemberController.java b/services/spring-member/src/main/java/tum/devoops/memberservice/controller/MemberController.java new file mode 100644 index 0000000..f8a244c --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/controller/MemberController.java @@ -0,0 +1,154 @@ +package tum.devoops.memberservice.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import tum.devoops.memberservice.model.Member; +import tum.devoops.memberservice.model.MemberCreate; +import tum.devoops.memberservice.model.MemberSummary; +import tum.devoops.memberservice.service.MemberService; + +import java.net.URI; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@RestController +public class MemberController { + + @Autowired + MemberService memberService; + + /** + * Retrieves all members. + *

+ * This endpoint searches the primary database and returns all members. + * Only the MemberSummary is returned. + *

+ * @return ResponseEntity containing a List of MemberSummary and HTTP 200 + */ + @PreAuthorize("hasAnyRole('member', 'admin')") + @GetMapping("/") + public ResponseEntity> getAllMembers() { + return ResponseEntity.ok(memberService.getAllMembers()); + } + + /** + * Retrieves a member given its ID. + *

+ * This endpoint searches the primary database and returns the corresponding member to an ID. + *

+ * @param id The unique {@link UUID} of the member. + * @return ResponseEntity containing a MemberSummary and HTTP 200. If the member is not found an empty + * ResponseEntity with HTTP 404 is returned. + */ + @PreAuthorize("hasAnyRole('member', 'admin')") + @GetMapping("/{id}") + public ResponseEntity getMemberSummaryById(@PathVariable UUID id) { + Optional memberOptional = memberService.getMemberSummaryById(id); + + if (memberOptional.isPresent()) { + MemberSummary member = memberOptional.get(); + return ResponseEntity.ok(member); + } else { + return ResponseEntity.notFound().build(); + } + } + + /** + * Retrieves a member with all details given its ID. + *

+ * This endpoint searches the primary database and returns the corresponding member to an ID. + *

+ * @param id The unique {@link UUID} of the member. + * @return ResponseEntity containing a Member and HTTP 200. If the member is not found an empty + * ResponseEntity with HTTP 404 is returned. + */ + @PreAuthorize("hasAnyRole('member', 'admin')") + @GetMapping("/{id}/details") + public ResponseEntity getMemberById(@PathVariable UUID id) { + Optional memberOptional = memberService.getMemberById(id); + + if (memberOptional.isPresent()) { + Member member = memberOptional.get(); + return ResponseEntity.ok(member); + } else { + return ResponseEntity.notFound().build(); + } + } + + /** + * Creates a member in the member-db and the corresponding user in keycloak + *

+ * This endpoint creates a member and further creates a corresponding user in keycloak using the email of the member as username. + * If the email is null, the username is firstName.lastName + *

+ * @param memberCreate the member without id. + * @return ResponseEntity containing the created member and HTTP 201. If the email or username exists, returns HTTP 400. + */ + @PreAuthorize("hasRole('admin')") + @PostMapping("/") + public ResponseEntity createMember(@RequestBody MemberCreate memberCreate, @AuthenticationPrincipal Jwt jwt) { + Optional optionalMember = memberService.createMember(memberCreate, jwt.getTokenValue()); + + if (optionalMember.isEmpty()) { + return ResponseEntity.badRequest().build(); + } + + Member member = optionalMember.get(); + + return ResponseEntity.created(URI.create("/" + member.getId())).body(member); + } + + /** + * Updates a member in the member db and the keycloak db (if email changes) + *

+ * This endpoint updates a member and further updates the corresponding user in keycloak. + *

+ * @param newMember the updated member. + * @return ResponseEntity containing the updated member and HTTP 200. If the member does not exist, return HTTP 404. + */ + @PreAuthorize("hasRole('admin') or hasRole('member') and #newMember.id.toString() == authentication.name") + @PutMapping("/{id}") + public ResponseEntity updateMember(@PathVariable UUID id, @RequestBody Member newMember, @AuthenticationPrincipal Jwt jwt) { + + Optional newMemberOptional = memberService.updateMember(newMember, jwt.getTokenValue()); + + if (newMemberOptional.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + newMember = newMemberOptional.get(); + return ResponseEntity.ok(newMember); + } + + /** + * Deletes a member in the member db and the keycloak db + *

+ * This endpoint deletes a member and further deletes the corresponding user in keycloak. + *

+ * @param id the id of the member to be deleted. + * @return Empty response and HTTP 204. If the member does not exist, return HTTP 404. + */ + @PreAuthorize("hasRole('admin')") + @DeleteMapping("/{id}") + public ResponseEntity deleteMember(@PathVariable UUID id, @AuthenticationPrincipal Jwt jwt) { + + boolean isDeleted = memberService.deleteMember(id, jwt.getTokenValue()); + + if (isDeleted) { + return ResponseEntity.noContent().build(); + } else { + return ResponseEntity.notFound().build(); + } + } +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/converter/MemberConverter.java b/services/spring-member/src/main/java/tum/devoops/memberservice/converter/MemberConverter.java new file mode 100644 index 0000000..863fc99 --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/converter/MemberConverter.java @@ -0,0 +1,57 @@ +package tum.devoops.memberservice.converter; + +import tum.devoops.memberservice.entity.MemberEntity; +import tum.devoops.memberservice.model.Member; +import tum.devoops.memberservice.model.MemberCreate; +import tum.devoops.memberservice.model.MemberSummary; + +import java.time.LocalDate; +import java.util.UUID; + +public class MemberConverter { + public static Member convertMemberEntityToMember(MemberEntity memberEntity) { + return new Member( + memberEntity.getId(), + memberEntity.getFirstName(), + memberEntity.getLastName(), + memberEntity.getEmail(), + memberEntity.getBirthday(), + memberEntity.getPhoneNumber(), + memberEntity.getAddress(), + memberEntity.getJoiningDate(), + memberEntity.getInformation() + ); + } + + public static MemberEntity convertMemberToMemberEntity(Member member) { + return new MemberEntity( + member.getId(), + member.getFirstName(), + member.getLastName(), + member.getEmail(), + member.getBirthday(), + member.getPhoneNumber(), + member.getAddress(), + member.getJoiningDate(), + member.getInformation() + ); + } + + public static MemberEntity convertMemberCreateToMemberEntity(MemberCreate memberCreate, UUID id) { + return new MemberEntity( + id, + memberCreate.getFirstName(), + memberCreate.getLastName(), + memberCreate.getEmail(), + memberCreate.getBirthday(), + memberCreate.getPhoneNumber(), + memberCreate.getAddress(), + LocalDate.now(), + memberCreate.getInformation() + ); + } + + public static MemberSummary convertMemberEntityToMemberSummary(MemberEntity memberEntity) { + return new MemberSummary(memberEntity.getId(), memberEntity.getFirstName(), memberEntity.getLastName(), memberEntity.getEmail()); + } +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/entity/MemberEntity.java b/services/spring-member/src/main/java/tum/devoops/memberservice/entity/MemberEntity.java index 54233e6..9b1586f 100644 --- a/services/spring-member/src/main/java/tum/devoops/memberservice/entity/MemberEntity.java +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/entity/MemberEntity.java @@ -9,13 +9,14 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @Entity @Table(schema = "member", name = "members") -@Getter @Setter @NoArgsConstructor +@Getter @Setter @NoArgsConstructor @AllArgsConstructor public class MemberEntity { @Id @@ -46,4 +47,18 @@ public class MemberEntity { @Column(name = "information", nullable = true, columnDefinition = "TEXT") private String information; + + @Override + public boolean equals(Object o) { + if (!(o instanceof MemberEntity)) { + return false; + } + MemberEntity other = (MemberEntity) o; + + return id.equals(other.getId()) && firstName.equals(other.getFirstName()) + && lastName.equals(other.getLastName()) && email.equals(other.getEmail()) + && birthday.equals(other.getBirthday()) && phoneNumber.equals(other.getPhoneNumber()) + && address.equals(other.getAddress()) && joiningDate.equals(other.getJoiningDate()) + && information.equals(other.getInformation()); + } } diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/service/KeycloakService.java b/services/spring-member/src/main/java/tum/devoops/memberservice/service/KeycloakService.java new file mode 100644 index 0000000..6205891 --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/service/KeycloakService.java @@ -0,0 +1,97 @@ +package tum.devoops.memberservice.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClient; +import tum.devoops.memberservice.model.Member; +import tum.devoops.memberservice.model.MemberCreate; + +import java.net.URI; +import java.util.UUID; + +@Service +public class KeycloakService { + + private final RestClient restClient; + @Value("${keycloak.realm}") + private String realm; + + public KeycloakService(RestClient.Builder restClientBuilder, @Value("${keycloak.base-url}") String baseUrl) { + this.restClient = restClientBuilder.baseUrl(baseUrl).build(); + } + + public UUID createUser(MemberCreate member, String bearerToken) throws Exception { + String username = member.getEmail() != null ? member.getEmail() : (member.getFirstName() + member.getLastName()).toLowerCase(); + + UserRepresentation body = new UserRepresentation(username, member.getFirstName(), member.getLastName(), member.getEmail(), true); + + ResponseEntity response; + + try { + response = restClient.post() + .uri("/admin/realms/{realm}/users", realm) + .header("Authorization", "Bearer " + bearerToken) + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .toBodilessEntity(); + } catch (HttpClientErrorException.Conflict e) { + throw new IllegalAccessException("A keycloak user with this username/email already exists"); + } catch (HttpClientErrorException.Forbidden e) { + throw new SecurityException("Insufficient permissions to create a keycloak user"); + } + + URI location = response.getHeaders().getLocation(); + + if (location == null) { + throw new IllegalStateException("Keycloak did not return a location header after user creation"); + } + + String path = location.getPath(); + return UUID.fromString(path.substring(path.lastIndexOf("/") + 1)); + } + + public void updateUser(Member member, String bearerToken) throws HttpClientErrorException{ + + UserRepresentation body = new UserRepresentation(member.getEmail(), member.getFirstName(), + member.getLastName(), member.getEmail(), true); + + try { + restClient.put() + .uri("/admin/realms/{realm}/users/{id}", realm, member.getId()) + .header("Authorization", "Bearer " + bearerToken) + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .toBodilessEntity(); + } catch (HttpClientErrorException.Conflict e) { + throw new IllegalArgumentException("A Keycloak user with this email already exists"); + } catch (HttpClientErrorException.Forbidden e) { + throw new SecurityException("Insufficient permissions to update this Keycloak user"); + } + } + + public void deleteUser(UUID keycloakId, String bearerToken) throws HttpClientErrorException, SecurityException{ + try { + restClient.delete() + .uri("/admin/realms/{realm}/users/{id}", realm, keycloakId) + .header("Authorization", "Bearer " + bearerToken) + .retrieve() + .toBodilessEntity(); + } catch (HttpClientErrorException.NotFound e) { + throw new IllegalArgumentException("Keycloak user not found: " + keycloakId); + } catch (HttpClientErrorException.Forbidden e) { + throw new SecurityException("Insufficient permissions to delete a keycloak user"); + } + } + + private record UserRepresentation( + String username, + String firstName, + String lastName, String + email, boolean enabled + ) {} +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/service/MemberService.java b/services/spring-member/src/main/java/tum/devoops/memberservice/service/MemberService.java new file mode 100644 index 0000000..c2cdeb7 --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/service/MemberService.java @@ -0,0 +1,128 @@ +package tum.devoops.memberservice.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import tum.devoops.memberservice.converter.MemberConverter; +import tum.devoops.memberservice.entity.MemberEntity; +import tum.devoops.memberservice.model.Member; +import tum.devoops.memberservice.model.MemberCreate; +import tum.devoops.memberservice.model.MemberSummary; +import tum.devoops.memberservice.repository.MemberRepository; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +public class MemberService { + + @Autowired + KeycloakService keycloakService; + + @Autowired + MemberRepository memberRepository; + + public List getAllMembers() { + List members = memberRepository.findAll(); + List memberSummaries = new ArrayList<>(); + + for (MemberEntity memberEntity : members) { + memberSummaries.add(MemberConverter.convertMemberEntityToMemberSummary(memberEntity)); + } + + return memberSummaries; + } + + public Optional getMemberSummaryById(UUID id) { + Optional optionalMemberEntity = memberRepository.findById(id); + + if (optionalMemberEntity.isEmpty()) { + return Optional.empty(); + } + + MemberEntity memberEntity = optionalMemberEntity.get(); + MemberSummary memberSummary = MemberConverter.convertMemberEntityToMemberSummary(memberEntity); + + return Optional.of(memberSummary); + } + + public Optional getMemberById(UUID id) { + Optional optionalMemberEntity = memberRepository.findById(id); + + if (optionalMemberEntity.isEmpty()) { + return Optional.empty(); + } + + MemberEntity memberEntity = optionalMemberEntity.get(); + Member member = MemberConverter.convertMemberEntityToMember(memberEntity); + + return Optional.of(member); + } + + public Optional createMember(MemberCreate memberCreate, String bearerToken) { + // If a member with this email already exists + if (memberRepository.findByEmail(memberCreate.getEmail()).isPresent()) { + return Optional.empty(); + } + + UUID id; + try { + id = keycloakService.createUser(memberCreate, bearerToken); + } catch (Exception e) { + return Optional.empty(); + } + + MemberEntity memberEntity = MemberConverter.convertMemberCreateToMemberEntity(memberCreate, id); + memberEntity = memberRepository.save(memberEntity); + + Member member = MemberConverter.convertMemberEntityToMember(memberEntity); + + return Optional.of(member); + } + + public Optional updateMember(Member member, String bearerToken) { + + Optional memberEntityWithEmail = memberRepository.findByEmail(member.getEmail()); + + if (memberEntityWithEmail.isPresent()) { + // If a member other than the passed member has the email + if (!memberEntityWithEmail.get().getId().equals(member.getId())) { + return Optional.empty(); + } + } + + try { + keycloakService.updateUser(member, bearerToken); + } catch (Exception e) { + return Optional.empty(); + } + + MemberEntity memberEntity = MemberConverter.convertMemberToMemberEntity(member); + MemberEntity updatedMemberEntity = memberRepository.save(memberEntity); + Member updatedMember = MemberConverter.convertMemberEntityToMember(updatedMemberEntity); + + return Optional.of(updatedMember); + } + + public boolean deleteMember(UUID id, String bearerToken) { + + try { + keycloakService.deleteUser(id, bearerToken); + } catch (Exception e) { + return false; + } + + Optional optionalMemberEntity = memberRepository.findById(id); + + if (optionalMemberEntity.isEmpty()) { + return false; + } + + MemberEntity memberEntity = optionalMemberEntity.get(); + memberRepository.delete(memberEntity); + + return true; + } + +} diff --git a/services/spring-member/src/test/java/tum/devoops/memberservice/MemberServiceApplicationTests.java b/services/spring-member/src/test/java/tum/devoops/memberservice/MemberServiceApplicationTests.java deleted file mode 100644 index 0fc4502..0000000 --- a/services/spring-member/src/test/java/tum/devoops/memberservice/MemberServiceApplicationTests.java +++ /dev/null @@ -1,27 +0,0 @@ -package tum.devoops.memberservice; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.TestPropertySource; - -/** - * Context-load smoke test. - * - * DataSource and JPA auto-configurations are excluded so the test can run - * without a live PostgreSQL instance. - */ -@SpringBootTest(properties = { - "spring.autoconfigure.exclude=" + - "org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration," + - "org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration" -}) -@TestPropertySource(properties = { - "spring.jpa.hibernate.ddl-auto=none" -}) -class MemberServiceApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/services/spring-member/src/test/java/tum/devoops/memberservice/HelloControllerTest.java b/services/spring-member/src/test/java/tum/devoops/memberservice/controller/HelloControllerTest.java similarity index 98% rename from services/spring-member/src/test/java/tum/devoops/memberservice/HelloControllerTest.java rename to services/spring-member/src/test/java/tum/devoops/memberservice/controller/HelloControllerTest.java index c0efa3e..358f358 100644 --- a/services/spring-member/src/test/java/tum/devoops/memberservice/HelloControllerTest.java +++ b/services/spring-member/src/test/java/tum/devoops/memberservice/controller/HelloControllerTest.java @@ -1,4 +1,4 @@ -package tum.devoops.memberservice; +package tum.devoops.memberservice.controller; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; diff --git a/services/spring-member/src/test/java/tum/devoops/memberservice/controller/MemberControllerTest.java b/services/spring-member/src/test/java/tum/devoops/memberservice/controller/MemberControllerTest.java new file mode 100644 index 0000000..3868e16 --- /dev/null +++ b/services/spring-member/src/test/java/tum/devoops/memberservice/controller/MemberControllerTest.java @@ -0,0 +1,629 @@ +package tum.devoops.memberservice.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.test.context.support.WithAnonymousUser; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import tum.devoops.memberservice.config.SecurityConfig; +import tum.devoops.memberservice.model.Member; +import tum.devoops.memberservice.model.MemberCreate; +import tum.devoops.memberservice.model.MemberSummary; +import tum.devoops.memberservice.service.MemberService; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(MemberController.class) +@Import(SecurityConfig.class) +public class MemberControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private MemberService memberService; + + MemberSummary memberSummary; + MemberSummary memberSummary1; + private Member member; + private MemberCreate memberCreate; + private String mockToken; + private Member newMember; + + @BeforeEach + void setUp() { + UUID id = UUID.randomUUID(); + + memberSummary = new MemberSummary(id, "Alice", "Aberdeen", "alice.aberdeen@example.com"); + memberSummary1 = new MemberSummary(UUID.randomUUID(), "Bob", "Builder", "bob.the.builder@example.com"); + + member = new Member( + id, + "firstName", + "lastName", + "email@email.com", + LocalDate.now(), + "phoneNumber", + "address", + LocalDate.now(), + "information" + ); + + memberCreate = new MemberCreate(); + memberCreate.setFirstName(member.getFirstName()); + memberCreate.setLastName(member.getLastName()); + memberCreate.setEmail(member.getEmail()); + memberCreate.setPhoneNumber(member.getPhoneNumber()); + memberCreate.setAddress(member.getAddress()); + memberCreate.setInformation(member.getInformation()); + memberCreate.setBirthday(member.getBirthday()); + + mockToken = "mock-token"; + + newMember = new Member( + id, + "newFirstName", + "newLastName", + "newemail@email.com", + LocalDate.now(), + "newPhoneNumber", + "newAddress", + LocalDate.now(), + "newInformation" + ); + } + + // Test cases for createMember() endpoint + + // Verifies that a user with role "member" is allowed to get all members + @Test + @WithMockUser(roles = "member") + void getMembersAllowedForMember() throws Exception { + when(memberService.getAllMembers()).thenReturn(List.of()); + mockMvc.perform(get("/")) + .andExpect(status().isOk()); + } + + // Verifies that a user wit role "admin" is allowed to get all members + @Test + @WithMockUser(roles = "admin") + void getMembersAllowedForAdmin() throws Exception { + when(memberService.getAllMembers()).thenReturn(List.of()); + mockMvc.perform(get("/")) + .andExpect(status().isOk()); + } + + // Verifies that a user with a role other than "admin" and "member" is not allowed to get all members (401 forbidden) + @Test + @WithMockUser(roles = "guest") + void getMembersForbiddenForWrongRole() throws Exception { + when(memberService.getAllMembers()).thenReturn(List.of()); + mockMvc.perform(get("/")) + .andExpect(status().isForbidden()); + } + + // Verifies that an anonymous user is not allowed to get all members (403 unauthorized) + @Test + @WithAnonymousUser + void getMembersUnauthorizedForAnonymous() throws Exception { + when(memberService.getAllMembers()).thenReturn(List.of()); + mockMvc.perform(get("/")) + .andExpect(status().isUnauthorized()); + } + + // Verifies that the content type of the response is application/json + @Test + @WithMockUser(roles = "member") + void getMembersContentType() throws Exception { + List list = List.of(); + when(memberService.getAllMembers()).thenReturn(list); + + mockMvc.perform(get("/")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)); + } + + // Verifies that the endpoint returns an empty list if there are no members + @Test + @WithMockUser(roles = "member") + void getMembersEmptyList() throws Exception { + List list = List.of(); + when(memberService.getAllMembers()).thenReturn(list); + + mockMvc.perform(get("/")) + .andExpect(status().isOk()) + .andExpect(content().json("[]")); + } + + // Verifies that the endpoint returns the list of members correctly when the list is not empty + @Test + @WithMockUser(roles = "member") + void getMemberNonEmptyList() throws Exception { + List list = List.of(memberSummary, memberSummary1); + when(memberService.getAllMembers()).thenReturn(list); + + mockMvc.perform(get("/")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(list))); + } + + // Test cases for getMemberById() endpoint + + // Verifies that a user with role "member" is allowed to retrieve a member by ID + @Test + @WithMockUser(roles = "member") + void getMemberByIdAllowedForMember() throws Exception { + when(memberService.getMemberById(member.getId())).thenReturn(Optional.of(member)); + + mockMvc.perform(get(String.format("/%s/details", member.getId()), UUID.randomUUID())) + .andExpect(status().isOk()); + } + + // Verifies that a user with role "admin" is allowed to retrieve a member by ID + @Test + @WithMockUser(roles = "admin") + void getMemberByIdAllowedForAdmin() throws Exception { + when(memberService.getMemberById(member.getId())).thenReturn(Optional.of(member)); + + mockMvc.perform(get(String.format("/%s/details", member.getId()), UUID.randomUUID())) + .andExpect(status().isOk()); + } + + // Verifies that a user with a role other than admin and member is not allowed to get a member by ID (403 forbidden) + @Test + @WithMockUser(roles = "guest") + void getMemberByIdForbiddenForWrongRole() throws Exception { + when(memberService.getMemberById(member.getId())).thenReturn(Optional.of(member)); + + mockMvc.perform(get(String.format("/%s/details", member.getId()), UUID.randomUUID())) + .andExpect(status().isForbidden()); + } + + // Verifies that an anonymous user is not allowed to get a member by ID (401 unauthorized) + @Test + @WithAnonymousUser + void getMemberByIdUnauthorizedForAnonymous() throws Exception { + when(memberService.getMemberById(member.getId())).thenReturn(Optional.of(member)); + + mockMvc.perform(get(String.format("/%s/details", member.getId()), UUID.randomUUID())) + .andExpect(status().isUnauthorized()); + } + + // Verifies that the content type of the response is application/json + @Test + @WithMockUser(roles = "member") + void getMemberByIdContentType() throws Exception { + when(memberService.getMemberById(member.getId())).thenReturn(Optional.of(member)); + + mockMvc.perform(get(String.format("/%s/details", member.getId()), UUID.randomUUID())) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)); + } + + // Verifies that the entire member is returned correctly + @Test + @WithMockUser(roles = "member") + void getMemberByIdReturnsCorrectMember() throws Exception { + when(memberService.getMemberById(member.getId())).thenReturn(Optional.of(member)); + + mockMvc.perform(get(String.format("/%s/details", member.getId()))) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(member))); + } + + // Verifies that a 404 not found is returned, when no member for the given id is found + @Test + @WithMockUser(roles = "member") + void getMemberByIdReturnsNotFound() throws Exception { + UUID randomId = UUID.randomUUID(); + when(memberService.getMemberById(randomId)).thenReturn(Optional.empty()); + + mockMvc.perform(get(String.format("/%s/details", randomId))) + .andExpect(status().isNotFound()); + } + + // Test cases for getMemberSummaryById() endpoint + + // Verifies that a user with role "member" is allowed to retrieve a member by ID + @Test + @WithMockUser(roles = "member") + void getMemberSummaryByIdAllowedForMember() throws Exception { + when(memberService.getMemberSummaryById(member.getId())).thenReturn(Optional.of(memberSummary)); + + mockMvc.perform(get(String.format("/%s", member.getId()), UUID.randomUUID())) + .andExpect(status().isOk()); + } + + // Verifies that a user with role "admin" is allowed to retrieve a member by ID + @Test + @WithMockUser(roles = "admin") + void getMemberSummaryByIdAllowedForAdmin() throws Exception { + when(memberService.getMemberSummaryById(member.getId())).thenReturn(Optional.of(memberSummary)); + + mockMvc.perform(get(String.format("/%s", member.getId()), UUID.randomUUID())) + .andExpect(status().isOk()); + } + + // Verifies that a user with a role other than admin and member is not allowed to get a member by ID (403 forbidden) + @Test + @WithMockUser(roles = "guest") + void getMemberSummaryByIdForbiddenForWrongRole() throws Exception { + when(memberService.getMemberSummaryById(member.getId())).thenReturn(Optional.of(memberSummary)); + + mockMvc.perform(get(String.format("/%s", member.getId()), UUID.randomUUID())) + .andExpect(status().isForbidden()); + } + + // Verifies that an anonymous user is not allowed to get a member by ID (401 unauthorized) + @Test + @WithAnonymousUser + void getMemberSummaryByIdUnauthorizedForAnonymous() throws Exception { + when(memberService.getMemberSummaryById(member.getId())).thenReturn(Optional.of(memberSummary)); + + mockMvc.perform(get(String.format("/%s", member.getId()), UUID.randomUUID())) + .andExpect(status().isUnauthorized()); + } + + // Verifies that the content type of the response is application/json + @Test + @WithMockUser(roles = "member") + void getMemberSummaryByIdContentType() throws Exception { + when(memberService.getMemberSummaryById(member.getId())).thenReturn(Optional.of(memberSummary)); + + mockMvc.perform(get(String.format("/%s", member.getId()), UUID.randomUUID())) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)); + } + + // Verifies that the entire member is returned correctly + @Test + @WithMockUser(roles = "member") + void getMemberSummaryByIdReturnsCorrectMember() throws Exception { + when(memberService.getMemberSummaryById(member.getId())).thenReturn(Optional.of(memberSummary)); + + mockMvc.perform(get(String.format("/%s", member.getId()))) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(memberSummary))); + } + + // Verifies that a 404 not found is returned, when no member for the given id is found + @Test + @WithMockUser(roles = "member") + void getMemberSummaryByIdReturnsNotFound() throws Exception { + UUID randomId = UUID.randomUUID(); + when(memberService.getMemberById(randomId)).thenReturn(Optional.empty()); + + mockMvc.perform(get(String.format("/%s", randomId))) + .andExpect(status().isNotFound()); + } + + // Test cases for createMember() endpoint + + // Verifies that a user with role "admin" can create a member + @Test + void createMemberAllowedForAdmin() throws Exception { + when(memberService.createMember(memberCreate, mockToken)).thenReturn(Optional.of(member)); + + mockMvc.perform(post("/") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberCreate)) + .with(jwt() + .jwt(j -> j.tokenValue(mockToken)) + .authorities(new SimpleGrantedAuthority("ROLE_admin")) + )) + .andExpect(status().isCreated()) + .andExpect(content().json(objectMapper.writeValueAsString(member))); + + } + + // Verifies that a user with role "member" cannot create a member (403 forbidden) + @Test + @WithMockUser(roles = "member") + void createMemberNotAllowedForAdmin() throws Exception { + mockMvc.perform(post("/") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberCreate)) + ) + .andExpect(status().isForbidden()); + } + + // Verifies that an anonymous user cannot create a member (401 unauthorized) + @Test + @WithAnonymousUser + void createMemberNotAllowedForAnonymousUser() throws Exception { + mockMvc.perform(post("/") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberCreate)) + ) + .andExpect(status().isUnauthorized()); + } + + // Verifies that 400 (bad request) is returned when cannot create the member + @Test + void createMemberServiceThrows() throws Exception { + when(memberService.createMember(memberCreate, mockToken)).thenReturn(Optional.empty()); + + mockMvc.perform(post("/") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberCreate)) + .with(jwt() + .jwt(j -> j.tokenValue(mockToken)) + .authorities(new SimpleGrantedAuthority("ROLE_admin")) + )) + .andExpect(status().isBadRequest()); + } + + // Test for updateMember() endpoint + + // Verifies that a user with role "admin" is allowed to update a member + @Test + void updateMemberAllowedForAdmin() throws Exception { + when(memberService.updateMember(eq(newMember), anyString())).thenReturn(Optional.of(newMember)); + + mockMvc.perform(put(String.format("/%s", member.getId())) + .with(jwt() + .jwt(j -> j.subject(newMember.getId().toString())) + .authorities(new SimpleGrantedAuthority("ROLE_admin")) + ) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(newMember)) + ) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(newMember))); + } + + // Verifies that a user wit role "member" is forbidden to update a member that is not himself (401 forbidden) + @Test + void updateMemberNotAllowedForUserOtherId() throws Exception { + UUID randomId = UUID.randomUUID(); + mockMvc.perform(put(String.format("/%s", member.getId())) + .with(jwt() + .jwt(j -> j.subject(randomId.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_member")) + ) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(newMember)) + ) + .andExpect(status().isForbidden()); + } + + // Verifies that a user with role "member" is allowed to update himself + @Test + void updateMemberAllowedForUserSameId() throws Exception { + when(memberService.updateMember(eq(newMember), anyString())).thenReturn(Optional.of(newMember)); + + mockMvc.perform(put(String.format("/%s", member.getId())) + .with(jwt() + .jwt(j -> j.subject(member.getId().toString())) + .authorities(new SimpleGrantedAuthority("ROLE_member")) + ) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(newMember)) + ) + .andExpect(status().isOk()); + } + + // Verifies that a user with an undefined role other than "admin" and "member" is not allowed + // to update a member that is not himself (401 forbidden) + @Test + void updateMemberNotAllowedForUndefinedRoleOtherId() throws Exception { + UUID randomId = UUID.randomUUID(); + mockMvc.perform(put(String.format("/%s", member.getId())) + .with(jwt() + .jwt(j -> j.subject(randomId.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_guest")) + ) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(newMember)) + ) + .andExpect(status().isForbidden()); + } + + // Verifies that a user with an undefined role other than "admin" and "member" is not allowed + // to update a member that is himself (401 forbidden) + @Test + void updateMemberNotAllowedForUndefinedRoleSameId() throws Exception { + mockMvc.perform(put(String.format("/%s", member.getId())) + .with(jwt() + .jwt(j -> j.subject(member.getId().toString())) + .authorities(new SimpleGrantedAuthority("ROLE_guest")) + ) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(newMember)) + ) + .andExpect(status().isForbidden()); + } + + // Verifies that an anonymous user is not allowed to update a member (403 unauthorized) + @Test + @WithAnonymousUser + void updateMemberUnauthorizedForAnonymous() throws Exception { + mockMvc.perform(put(String.format("/%s", member.getId())) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(newMember)) + ) + .andExpect(status().isUnauthorized()); + } + + // Verifies that the content type of the response is application/json + @Test + void updateMemberContentType() throws Exception { + when(memberService.updateMember(eq(newMember), anyString())).thenReturn(Optional.of(newMember)); + + mockMvc.perform(put(String.format("/%s", member.getId())) + .with(jwt() + .jwt(j -> j.subject(member.getId().toString())) + .authorities(new SimpleGrantedAuthority("ROLE_admin")) + ) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(newMember)) + ) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)); + } + + // Verifies that the endpoint updates the member properly (200 ok) + @Test + void updateMemberCorrectUpdateForUserSameId() throws Exception { + when(memberService.updateMember(eq(newMember), anyString())).thenReturn(Optional.of(newMember)); + + mockMvc.perform(put(String.format("/%s", member.getId())) + .with(jwt() + .jwt(j -> j.subject(member.getId().toString())) + .authorities(new SimpleGrantedAuthority("ROLE_member")) + ) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(newMember)) + ) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(newMember))); + } + + // Verify that a non-existing member cannot be updated by an admin + @Test + void updateMemberNotFoundNonExistingId() throws Exception { + when(memberService.updateMember(eq(newMember), anyString())).thenReturn(Optional.empty()); + + mockMvc.perform(put(String.format("/%s", UUID.randomUUID())) + .with(jwt() + .jwt(j -> j.subject(newMember.getId().toString())) + .authorities(new SimpleGrantedAuthority("ROLE_admin")) + ) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(newMember)) + ) + .andExpect(status().isNotFound()); + } + + // Verify that an non-existing member cannot updated by a user even if the id is the same as the user + @Test + void updateMemberNotFoundUserSameId() throws Exception { + when(memberService.updateMember(eq(newMember), anyString())).thenReturn(Optional.empty()); + + mockMvc.perform(put(String.format("/%s", member.getId())) + .with(jwt() + .jwt(j -> j.subject(member.getId().toString())) + .authorities(new SimpleGrantedAuthority("ROLE_member")) + ) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(newMember)) + ) + .andExpect(status().isNotFound()); + } + + // Test for deleteMember() endpoint + + // Verifies that a user with role "admin" is allowed to delete a member (204 no content) + @Test + void deleteMemberAllowedForAdmin() throws Exception { + when(memberService.deleteMember(eq(member.getId()), anyString())).thenReturn(true); + + mockMvc.perform(delete(String.format("/%s", member.getId())) + .with(jwt() + .jwt(j -> j.subject(member.getId().toString())) + .authorities(new SimpleGrantedAuthority("ROLE_admin")) + ) + ) + .andExpect(status().isNoContent()); + } + + // Verifies that a user with role "member" is not allowed to delete a member that is not himself + @Test + @WithMockUser(roles = "member") + void deleteMemberNotAllowedForUserOtherId() throws Exception { + UUID randomId = UUID.randomUUID(); + mockMvc.perform(delete(String.format("/%s", member.getId())) + .with(jwt() + .jwt(j -> j.subject(randomId.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_member")) + ) + ) + .andExpect(status().isForbidden()); + } + + // Verifies that a user wit role "member" is forbidden to delete a member even though it is himself (401 forbidden) + @Test + void deleteMemberNotAllowedForUserSameId() throws Exception { + mockMvc.perform(delete(String.format("/%s", member.getId())) + .with(jwt() + .jwt(j -> j.subject(member.getId().toString())) + .authorities(new SimpleGrantedAuthority("ROLE_member")) + ) + ) + .andExpect(status().isForbidden()); + } + + // Verifies that a user with an undefined role other than "admin" and "member" is not allowed + // to delete a member that is not himself (401 forbidden) + @Test + void deleteMemberNotAllowedForUndefinedRoleOtherId() throws Exception { + UUID randomId = UUID.randomUUID(); + mockMvc.perform(delete(String.format("/%s", member.getId())) + .with(jwt() + .jwt(j -> j.subject(randomId.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_guest")) + ) + ) + .andExpect(status().isForbidden()); + } + + // Verifies that a user with an undefined role other than "admin" and "member" is not allowed + // to delete a member that is himself (401 forbidden) + @Test + void deleteMemberNotAllowedForUndefinedRoleSameId() throws Exception { + mockMvc.perform(delete(String.format("/%s", member.getId())) + .with(jwt() + .jwt(j -> j.subject(member.getId().toString())) + .authorities(new SimpleGrantedAuthority("ROLE_guest")) + ) + ) + .andExpect(status().isForbidden()); + } + + // Verifies that an anonymous user is not allowed to delete a member (403 unauthorized) + @Test + @WithAnonymousUser + void deleteMemberUnauthorizedForAnonymous() throws Exception { + mockMvc.perform(delete(String.format("/%s", member.getId()))) + .andExpect(status().isUnauthorized()); + } + + // Verify that a non-existing member cannot be deleted by an admin + @Test + @WithMockUser(roles = "admin") + void deleteMemberNotFoundNonExistingId() throws Exception { + when(memberService.deleteMember(eq(member.getId()), anyString())).thenReturn(false); + + mockMvc.perform(delete(String.format("/%s", member.getId())) + .with(jwt() + .jwt(j -> j.subject(member.getId().toString())) + .authorities(new SimpleGrantedAuthority("ROLE_admin")) + ) + ) + .andExpect(status().isNotFound()); + } +} diff --git a/services/spring-member/src/test/java/tum/devoops/memberservice/converter/MemberConverterTest.java b/services/spring-member/src/test/java/tum/devoops/memberservice/converter/MemberConverterTest.java new file mode 100644 index 0000000..c2dc632 --- /dev/null +++ b/services/spring-member/src/test/java/tum/devoops/memberservice/converter/MemberConverterTest.java @@ -0,0 +1,151 @@ +package tum.devoops.memberservice.converter; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import tum.devoops.memberservice.entity.MemberEntity; +import tum.devoops.memberservice.model.Member; +import tum.devoops.memberservice.model.MemberCreate; +import tum.devoops.memberservice.model.MemberSummary; + +import java.time.LocalDate; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MemberConverterTest { + + UUID id; + + private MemberEntity memberEntity; + private Member member; + private MemberCreate memberCreate; + + @BeforeEach + void setUp() { + id = UUID.randomUUID(); + LocalDate birthday = LocalDate.of(1990, 1, 1); + LocalDate joiningDate = LocalDate.of(2020, 6, 15); + + memberEntity = new MemberEntity( + id, + "firstName", + "lastName", + "email@email.com", + birthday, + "phoneNumber", + "address", + joiningDate, + "information" + ); + + member = new Member( + id, + "firstName", + "lastName", + "email@email.com", + birthday, + "phoneNumber", + "address", + joiningDate, + "information" + ); + + memberCreate = new MemberCreate(); + memberCreate.setFirstName("firstName"); + memberCreate.setLastName("lastName"); + memberCreate.setEmail("email@email.com"); + memberCreate.setBirthday(birthday); + memberCreate.setPhoneNumber("phoneNumber"); + memberCreate.setAddress("address"); + memberCreate.setInformation("information"); + } + + // Verifies that every field of a MemberEntity is mapped onto the resulting Member + @Test + void convertMemberEntityToMemberMapsAllFields() { + Member result = MemberConverter.convertMemberEntityToMember(memberEntity); + + assertEquals(id, result.getId()); + assertEquals(memberEntity.getFirstName(), result.getFirstName()); + assertEquals(memberEntity.getLastName(), result.getLastName()); + assertEquals(memberEntity.getEmail(), result.getEmail()); + assertEquals(memberEntity.getBirthday(), result.getBirthday()); + assertEquals(memberEntity.getPhoneNumber(), result.getPhoneNumber()); + assertEquals(memberEntity.getAddress(), result.getAddress()); + assertEquals(memberEntity.getJoiningDate(), result.getJoiningDate()); + assertEquals(memberEntity.getInformation(), result.getInformation()); + } + + // Verifies that every field of a Member is mapped onto the resulting MemberEntity + @Test + void convertMemberToMemberEntityMapsAllFields() { + MemberEntity result = MemberConverter.convertMemberToMemberEntity(member); + + assertEquals(member.getId(), result.getId()); + assertEquals(member.getFirstName(), result.getFirstName()); + assertEquals(member.getLastName(), result.getLastName()); + assertEquals(member.getEmail(), result.getEmail()); + assertEquals(member.getBirthday(), result.getBirthday()); + assertEquals(member.getPhoneNumber(), result.getPhoneNumber()); + assertEquals(member.getAddress(), result.getAddress()); + assertEquals(member.getJoiningDate(), result.getJoiningDate()); + assertEquals(member.getInformation(), result.getInformation()); + } + + // Verifies that the provided id is used, the MemberCreate fields are copied and joiningDate is set to today + @Test + void convertMemberCreateToMemberEntityMapsFieldsAndSetsJoiningDate() { + LocalDate before = LocalDate.now(); + MemberEntity result = MemberConverter.convertMemberCreateToMemberEntity(memberCreate, id); + LocalDate after = LocalDate.now(); + + assertEquals(id, result.getId()); + assertEquals(memberCreate.getFirstName(), result.getFirstName()); + assertEquals(memberCreate.getLastName(), result.getLastName()); + assertEquals(memberCreate.getEmail(), result.getEmail()); + assertEquals(memberCreate.getBirthday(), result.getBirthday()); + assertEquals(memberCreate.getPhoneNumber(), result.getPhoneNumber()); + assertEquals(memberCreate.getAddress(), result.getAddress()); + assertEquals(memberCreate.getInformation(), result.getInformation()); + + // joiningDate is overridden with the current date rather than taken from the input + assertTrue(!result.getJoiningDate().isBefore(before) && !result.getJoiningDate().isAfter(after)); + } + + // Verifies that only id, firstName, lastName and email are mapped onto the summary + @Test + void convertMemberEntityToMemberSummaryMapsSummaryFields() { + MemberSummary result = MemberConverter.convertMemberEntityToMemberSummary(memberEntity); + + assertEquals(id, result.getId()); + assertEquals(memberEntity.getFirstName(), result.getFirstName()); + assertEquals(memberEntity.getLastName(), result.getLastName()); + assertEquals(memberEntity.getEmail(), result.getEmail()); + } + + // Verifies that null optional fields are preserved through the conversion + @Test + void convertMemberEntityToMemberPreservesNullOptionalFields() { + MemberEntity entity = new MemberEntity( + id, + "firstName", + "lastName", + "email@email.com", + null, + null, + null, + null, + null + ); + + Member result = MemberConverter.convertMemberEntityToMember(entity); + + assertNull(result.getBirthday()); + assertNull(result.getPhoneNumber()); + assertNull(result.getAddress()); + assertNull(result.getJoiningDate()); + assertNull(result.getInformation()); + } +} diff --git a/services/spring-member/src/test/java/tum/devoops/memberservice/service/KeycloakServiceTest.java b/services/spring-member/src/test/java/tum/devoops/memberservice/service/KeycloakServiceTest.java new file mode 100644 index 0000000..5444e7c --- /dev/null +++ b/services/spring-member/src/test/java/tum/devoops/memberservice/service/KeycloakServiceTest.java @@ -0,0 +1,169 @@ +package tum.devoops.memberservice.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; +import tum.devoops.memberservice.model.Member; +import tum.devoops.memberservice.model.MemberCreate; + +import java.time.LocalDate; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; + +class KeycloakServiceTest { + + private static final String BASE_URL = "http://keycloak.test"; + private static final String REALM = "test-realm"; + private static final String TOKEN = "mock-token"; + private static final String USERS_URI = BASE_URL + "/admin/realms/" + REALM + "/users"; + + private MockRestServiceServer server; + private KeycloakService keycloakService; + + private UUID id; + private MemberCreate memberCreate; + private Member member; + + @BeforeEach + void setUp() { + RestClient.Builder builder = RestClient.builder(); + server = MockRestServiceServer.bindTo(builder).build(); + + keycloakService = new KeycloakService(builder, BASE_URL); + ReflectionTestUtils.setField(keycloakService, "realm", REALM); + + id = UUID.randomUUID(); + + memberCreate = new MemberCreate(); + memberCreate.setFirstName("firstName"); + memberCreate.setLastName("lastName"); + memberCreate.setEmail("email@email.com"); + + member = new Member( + id, + "firstName", + "lastName", + "email@email.com", + LocalDate.of(1990, 1, 1), + "phoneNumber", + "address", + LocalDate.of(2020, 6, 15), + "information" + ); + } + + // Verifies that a successful creation returns the id parsed from the Location header + @Test + void createUserReturnsIdFromLocationHeader() throws Exception { + server.expect(requestTo(USERS_URI)) + .andRespond(withStatus(HttpStatus.CREATED) + .header(HttpHeaders.LOCATION, USERS_URI + "/" + id)); + + UUID result = keycloakService.createUser(memberCreate, TOKEN); + + assertEquals(id, result); + } + + // Verifies that creation succeeds when no email is set (username falls back to the member's name) + @Test + void createUserWithoutEmailReturnsId() throws Exception { + memberCreate.setEmail(null); + + server.expect(requestTo(USERS_URI)) + .andRespond(withStatus(HttpStatus.CREATED) + .header(HttpHeaders.LOCATION, USERS_URI + "/" + id)); + + UUID result = keycloakService.createUser(memberCreate, TOKEN); + + assertEquals(id, result); + } + + // Verifies that a 409 conflict is translated into an IllegalAccessException + @Test + void createUserThrowsOnConflict() { + server.expect(requestTo(USERS_URI)) + .andRespond(withStatus(HttpStatus.CONFLICT)); + + assertThrows(IllegalAccessException.class, () -> keycloakService.createUser(memberCreate, TOKEN)); + } + + // Verifies that a 403 forbidden is translated into a SecurityException + @Test + void createUserThrowsOnForbidden() { + server.expect(requestTo(USERS_URI)) + .andRespond(withStatus(HttpStatus.FORBIDDEN)); + + assertThrows(SecurityException.class, () -> keycloakService.createUser(memberCreate, TOKEN)); + } + + // Verifies that a creation without a Location header fails with an IllegalStateException + @Test + void createUserThrowsWhenNoLocationHeader() { + server.expect(requestTo(USERS_URI)) + .andRespond(withStatus(HttpStatus.CREATED)); + + assertThrows(IllegalStateException.class, () -> keycloakService.createUser(memberCreate, TOKEN)); + } + + // Verifies that a successful update completes without throwing + @Test + void updateUserSucceeds() { + server.expect(requestTo(USERS_URI + "/" + id)) + .andRespond(withStatus(HttpStatus.NO_CONTENT)); + + keycloakService.updateUser(member, TOKEN); + } + + // Verifies that a 409 conflict is translated into an IllegalArgumentException + @Test + void updateUserThrowsOnConflict() { + server.expect(requestTo(USERS_URI + "/" + id)) + .andRespond(withStatus(HttpStatus.CONFLICT)); + + assertThrows(IllegalArgumentException.class, () -> keycloakService.updateUser(member, TOKEN)); + } + + // Verifies that a 403 forbidden is translated into a SecurityException + @Test + void updateUserThrowsOnForbidden() { + server.expect(requestTo(USERS_URI + "/" + id)) + .andRespond(withStatus(HttpStatus.FORBIDDEN)); + + assertThrows(SecurityException.class, () -> keycloakService.updateUser(member, TOKEN)); + } + + // Verifies that a successful deletion completes without throwing + @Test + void deleteUserSucceeds() { + server.expect(requestTo(USERS_URI + "/" + id)) + .andRespond(withStatus(HttpStatus.NO_CONTENT)); + + keycloakService.deleteUser(id, TOKEN); + } + + // Verifies that a 404 not found is translated into an IllegalArgumentException + @Test + void deleteUserThrowsOnNotFound() { + server.expect(requestTo(USERS_URI + "/" + id)) + .andRespond(withStatus(HttpStatus.NOT_FOUND)); + + assertThrows(IllegalArgumentException.class, () -> keycloakService.deleteUser(id, TOKEN)); + } + + // Verifies that a 403 forbidden is translated into a SecurityException + @Test + void deleteUserThrowsOnForbidden() { + server.expect(requestTo(USERS_URI + "/" + id)) + .andRespond(withStatus(HttpStatus.FORBIDDEN)); + + assertThrows(SecurityException.class, () -> keycloakService.deleteUser(id, TOKEN)); + } +} diff --git a/services/spring-member/src/test/java/tum/devoops/memberservice/service/MemberServiceTest.java b/services/spring-member/src/test/java/tum/devoops/memberservice/service/MemberServiceTest.java new file mode 100644 index 0000000..2b74490 --- /dev/null +++ b/services/spring-member/src/test/java/tum/devoops/memberservice/service/MemberServiceTest.java @@ -0,0 +1,297 @@ +package tum.devoops.memberservice.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import tum.devoops.memberservice.entity.MemberEntity; +import tum.devoops.memberservice.model.Member; +import tum.devoops.memberservice.model.MemberCreate; +import tum.devoops.memberservice.model.MemberSummary; +import tum.devoops.memberservice.repository.MemberRepository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MemberServiceTest { + + private static final String TOKEN = "mock-token"; + + @Mock + private MemberRepository memberRepository; + + @Mock + private KeycloakService keycloakService; + + @InjectMocks + private MemberService memberService; + + private UUID id; + private MemberEntity memberEntity; + private Member member; + private MemberSummary expectedSummary; + private MemberCreate memberCreate; + + @BeforeEach + void setUp() { + id = UUID.randomUUID(); + + memberEntity = new MemberEntity( + id, + "firstName", + "lastName", + "email@email.com", + LocalDate.of(1990, 1, 1), + "phoneNumber", + "address", + LocalDate.of(2020, 6, 15), + "information" + ); + + member = new Member( + id, + "firstName", + "lastName", + "email@email.com", + LocalDate.of(1990, 1, 1), + "phoneNumber", + "address", + LocalDate.of(2020, 6, 15), + "information" + ); + + expectedSummary = new MemberSummary(id, "firstName", "lastName", "email@email.com"); + + memberCreate = new MemberCreate(); + memberCreate.setFirstName("firstName"); + memberCreate.setLastName("lastName"); + memberCreate.setEmail("email@email.com"); + memberCreate.setBirthday(LocalDate.of(1990, 1, 1)); + memberCreate.setPhoneNumber("phoneNumber"); + memberCreate.setAddress("address"); + memberCreate.setInformation("information"); + } + + // Test cases for getAllMembers() + + // Verifies that an empty list is returned when the repository holds no members + @Test + void getAllMembersReturnsEmptyListWhenNoMembers() { + when(memberRepository.findAll()).thenReturn(List.of()); + + List result = memberService.getAllMembers(); + + assertTrue(result.isEmpty()); + } + + // Verifies that each entity is converted into a MemberSummary with its fields mapped + @Test + void getAllMembersReturnsSummaryPerEntity() { + when(memberRepository.findAll()).thenReturn(List.of(memberEntity)); + + List result = memberService.getAllMembers(); + + assertEquals(1, result.size()); + assertEquals(expectedSummary, result.getFirst()); + } + + // Test cases for getMemberSummaryById() + + // Verifies that a populated summary is returned when the member exists + @Test + void getMemberSummaryByIdReturnsSummaryWhenFound() { + when(memberRepository.findById(id)).thenReturn(Optional.of(memberEntity)); + + Optional result = memberService.getMemberSummaryById(id); + + assertTrue(result.isPresent()); + assertEquals(expectedSummary, result.get()); + } + + // Verifies that an empty optional is returned when the member does not exist + @Test + void getMemberSummaryByIdReturnsEmptyWhenNotFound() { + when(memberRepository.findById(id)).thenReturn(Optional.empty()); + + Optional result = memberService.getMemberSummaryById(id); + + assertTrue(result.isEmpty()); + } + + // Test cases for getMemberById() + + // Verifies that the full member is returned when it exists + @Test + void getMemberByIdReturnsMemberWhenFound() { + when(memberRepository.findById(id)).thenReturn(Optional.of(memberEntity)); + + Optional result = memberService.getMemberById(id); + + assertTrue(result.isPresent()); + assertEquals(member, result.get()); + } + + // Verifies that an empty optional is returned when the member does not exist + @Test + void getMemberByIdReturnsEmptyWhenNotFound() { + when(memberRepository.findById(id)).thenReturn(Optional.empty()); + + Optional result = memberService.getMemberById(id); + + assertTrue(result.isEmpty()); + } + + // Test cases for createMember() + + // Verifies that creation is rejected when a member with the same email already exists + @Test + void createMemberReturnsEmptyWhenEmailExists() { + when(memberRepository.findByEmail("email@email.com")).thenReturn(Optional.of(memberEntity)); + + Optional result = memberService.createMember(memberCreate, TOKEN); + + assertTrue(result.isEmpty()); + verifyNoInteractions(keycloakService); + verify(memberRepository, never()).save(any()); + } + + // Verifies that creation is rejected and nothing is persisted when Keycloak fails + @Test + void createMemberReturnsEmptyWhenKeycloakThrows() throws Exception { + when(memberRepository.findByEmail("email@email.com")).thenReturn(Optional.empty()); + when(keycloakService.createUser(memberCreate, TOKEN)).thenThrow(new RuntimeException("keycloak down")); + + Optional result = memberService.createMember(memberCreate, TOKEN); + + assertTrue(result.isEmpty()); + verify(memberRepository, never()).save(any()); + } + + // Verifies that a member is created and returned when the email is free and Keycloak succeeds + @Test + void createMemberReturnsMemberOnSuccess() throws Exception { + when(memberRepository.findByEmail("email@email.com")).thenReturn(Optional.empty()); + when(keycloakService.createUser(memberCreate, TOKEN)).thenReturn(id); + when(memberRepository.save(any(MemberEntity.class))).thenReturn(memberEntity); + + Optional result = memberService.createMember(memberCreate, TOKEN); + + assertTrue(result.isPresent()); + assertEquals(member, result.get()); + verify(memberRepository).save(any(MemberEntity.class)); + } + + // Test cases for updateMember() + + // Verifies that an update is rejected when the email belongs to a different member + @Test + void updateMemberReturnsEmptyWhenEmailTakenByOther() { + MemberEntity otherMember = new MemberEntity( + UUID.randomUUID(), + "other", + "other", + "email@email.com", + LocalDate.of(1990, 1, 1), + "phoneNumber", + "address", + LocalDate.of(2020, 6, 15), + "information" + ); + when(memberRepository.findByEmail("email@email.com")).thenReturn(Optional.of(otherMember)); + + Optional result = memberService.updateMember(member, TOKEN); + + assertTrue(result.isEmpty()); + verifyNoInteractions(keycloakService); + verify(memberRepository, never()).save(any()); + } + + // Verifies that an update succeeds when the email belongs to the same member + @Test + void updateMemberReturnsMemberWhenEmailBelongsToSameMember() { + when(memberRepository.findByEmail("email@email.com")).thenReturn(Optional.of(memberEntity)); + when(memberRepository.save(any(MemberEntity.class))).thenReturn(memberEntity); + + Optional result = memberService.updateMember(member, TOKEN); + + assertTrue(result.isPresent()); + assertEquals(member, result.get()); + verify(keycloakService).updateUser(member, TOKEN); + verify(memberRepository).save(any(MemberEntity.class)); + } + + // Verifies that an update succeeds when the email is not used by anyone + @Test + void updateMemberReturnsMemberWhenEmailUnused() { + when(memberRepository.findByEmail("email@email.com")).thenReturn(Optional.empty()); + when(memberRepository.save(any(MemberEntity.class))).thenReturn(memberEntity); + + Optional result = memberService.updateMember(member, TOKEN); + + assertTrue(result.isPresent()); + assertEquals(member, result.get()); + verify(memberRepository).save(any(MemberEntity.class)); + } + + // Verifies that an update is rejected and nothing is persisted when Keycloak fails + @Test + void updateMemberReturnsEmptyWhenKeycloakThrows() { + when(memberRepository.findByEmail("email@email.com")).thenReturn(Optional.empty()); + doThrow(new RuntimeException("keycloak down")).when(keycloakService).updateUser(member, TOKEN); + + Optional result = memberService.updateMember(member, TOKEN); + + assertTrue(result.isEmpty()); + verify(memberRepository, never()).save(any()); + } + + // Test cases for deleteMember() + + // Verifies that deletion fails and nothing is removed when Keycloak fails + @Test + void deleteMemberReturnsFalseWhenKeycloakThrows() { + doThrow(new RuntimeException("keycloak down")).when(keycloakService).deleteUser(id, TOKEN); + + boolean result = memberService.deleteMember(id, TOKEN); + + assertFalse(result); + verify(memberRepository, never()).delete(any()); + } + + // Verifies that deletion fails when the member does not exist in the repository + @Test + void deleteMemberReturnsFalseWhenMemberNotFound() { + when(memberRepository.findById(id)).thenReturn(Optional.empty()); + + boolean result = memberService.deleteMember(id, TOKEN); + + assertFalse(result); + verify(memberRepository, never()).delete(any()); + } + + // Verifies that deletion succeeds and the entity is removed when it exists + @Test + void deleteMemberReturnsTrueOnSuccess() { + when(memberRepository.findById(id)).thenReturn(Optional.of(memberEntity)); + + boolean result = memberService.deleteMember(id, TOKEN); + + assertTrue(result); + verify(memberRepository).delete(memberEntity); + } +} diff --git a/services/spring-member/src/test/java/tum/devoops/memberservice/SecurityConfigTest.java b/services/spring-member/src/test/java/tum/devoops/memberservice/service/SecurityConfigTest.java similarity index 98% rename from services/spring-member/src/test/java/tum/devoops/memberservice/SecurityConfigTest.java rename to services/spring-member/src/test/java/tum/devoops/memberservice/service/SecurityConfigTest.java index 48c6216..bb67044 100644 --- a/services/spring-member/src/test/java/tum/devoops/memberservice/SecurityConfigTest.java +++ b/services/spring-member/src/test/java/tum/devoops/memberservice/service/SecurityConfigTest.java @@ -1,4 +1,4 @@ -package tum.devoops.memberservice; +package tum.devoops.memberservice.service; import java.util.List; import java.util.Map;