diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b44df56234ecbb7871237cdad4f35e5fb3d22ebf..20e82f781093ce7c81eb104f12e2244cee903a95 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,11 +1,29 @@ -# Based on https://gitlab.fi.muni.cz/unix/examples/ci-examples/-/blob/java-maven/.gitlab-ci.yml -image: maven:3-openjdk-17 - +image: 'maven:3.8.5-openjdk-17-slim' +cache: + paths: + - .m2/repository +variables: + MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository" + MAVEN_CLI_OPTS: "--batch-mode --errors --fail-at-end --show-version" +stages: + - build + - test build: tags: - shared-fi + stage: build + script: + - echo "We are building your project, $GITLAB_USER_LOGIN" + - mvn clean install -Dmaven.test.skip=true $MAVEN_CLI_OPTS +test: + tags: + - shared-fi + stage: test script: - - mvn -ntp clean package + - echo "Starting tests ..." + - mvn test + + diff --git a/core/src/main/java/cz/muni/fi/pa165/core/common/DomainService.java b/core/src/main/java/cz/muni/fi/pa165/core/common/DomainService.java index 17a93eb3d9d3388b651b81d4fcf96a3141d418db..55fec5d0884d4191b45a32adfea9a4aa746d6bb1 100644 --- a/core/src/main/java/cz/muni/fi/pa165/core/common/DomainService.java +++ b/core/src/main/java/cz/muni/fi/pa165/core/common/DomainService.java @@ -9,75 +9,133 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.List; - +/** + * A generic service for domain objects. + * + * @author Marek SkácelĂk + * @param <T> the type of the domain object. + */ public abstract class DomainService<T extends DomainObject> { - public static final int DEFAULT_PAGE_SIZE = 10; + public static final int DEFAULT_PAGE_SIZE = 10; - public abstract JpaRepository<T, String> getRepository(); + /** + * Returns the repository for the domain object. + * + * @return the repository. + */ + public abstract JpaRepository<T, String> getRepository(); - @Transactional - public T create(T entity) - { - entity.createdDateTime = LocalDateTime.now(); - return getRepository().save(entity); - } + /** + * Creates the given entity. + * + * @param entity the entity to create. + * @return the created entity. + */ + @Transactional + public T create(T entity) { + entity.createdDateTime = LocalDateTime.now(); + return getRepository().save(entity); + } - @Transactional(readOnly = true) - public Page<T> findAllPageable(Pageable pageable) { - return getRepository().findAll(pageable); - } + /** + * Returns all entities as a pageable list. + * + * @param pageable the pageable parameters. + * @return a pageable list of all entities. + */ + @Transactional(readOnly = true) + public Page<T> findAllPageable(Pageable pageable) { + return getRepository().findAll(pageable); + } - @Transactional(readOnly = true) - public List<T> findAll() { - return getRepository().findAll().stream().filter(entity -> entity.deletedDateTime == null).toList(); - } + /** + * Returns all entities. + * + * @return a list of all entities. + */ + @Transactional(readOnly = true) + public List<T> findAll() { + return getRepository().findAll().stream().filter(entity -> entity.deletedDateTime == null).toList(); + } - @Transactional(readOnly = true) - public Page<T> findAllPageableInt(int page) { - return getRepository().findAll(PageRequest.of(page, DEFAULT_PAGE_SIZE))/*.filter(entity -> entity.deletedDateTime == null)*/; - } + /** + * Returns all entities as a pageable list. + * + * @param page the page number. + * @return a pageable list of all entities. + */ + @Transactional(readOnly = true) + public Page<T> findAllPageableInt(int page) { + return getRepository().findAll(PageRequest.of(page, DEFAULT_PAGE_SIZE))/*.filter(entity -> entity.deletedDateTime == null)*/; + } - @Transactional(readOnly = true) - public T findById(String id) { - return getRepository() - .findById(id) - .filter(entity -> entity.deletedDateTime == null) - .orElseThrow(() -> new EntityNotFoundException("Entity with '" + id + "' not found.")); - } + /** + * Returns the entity with the given ID. + * + * @param id the ID of the entity to retrieve. + * @return the entity with the given ID. + * @throws EntityNotFoundException if the entity does not exist. + */ + @Transactional(readOnly = true) + public T findById(String id) { + return getRepository() + .findById(id) + .filter(entity -> entity.deletedDateTime == null) + .orElseThrow(() -> new EntityNotFoundException("Entity with '" + id + "' not found.")); + } - @Transactional - public void deleteAll() - { - List<T> entities = findAll(); - entities.stream().map(entity -> entity.deletedDateTime = LocalDateTime.now()); - getRepository().saveAll(entities); - } + /** + * Deletes all entities by setting their deletion datetime. + */ + @Transactional + public void deleteAll() { + List<T> entities = findAll(); + entities.stream().map(entity -> entity.deletedDateTime = LocalDateTime.now()); + getRepository().saveAll(entities); + } - @Transactional - public T deleteById(String id) { - T entity = findById(id); - if (entity == null) - throw new EntityNotFoundException("Entity '" + id + "' not found."); - entity.deletedDateTime = LocalDateTime.now(); - getRepository().save(entity); - return entity; - } + /** + * Deletes the entity with the given ID by setting its deletion datetime. + * + * @param id the ID of the entity to delete. + * @return the deleted entity. + * @throws EntityNotFoundException if the entity does not exist. + */ + @Transactional + public T deleteById(String id) { + T entity = findById(id); + if (entity == null) + throw new EntityNotFoundException("Entity '" + id + "' not found."); + entity.deletedDateTime = LocalDateTime.now(); + getRepository().save(entity); + return entity; + } - @Transactional - public T update(T entityToUpdate, String id) { - T entity = findById(id); - if (entity == null) - throw new EntityNotFoundException("Entity '" + id + "' not found."); - // TODO: change when ORM tool available - entityToUpdate.setId(id); - getRepository().save(entityToUpdate); - return entity; - } + /** + * Updates the entity with the given ID. + * + * @param entityToUpdate the updated entity. + * @param id the ID of the entity to update. + * @return the updated entity. + * @throws EntityNotFoundException if the entity does not exist. + */ + @Transactional + public T update(T entityToUpdate, String id) { + T entity = findById(id); + if (entity == null) + throw new EntityNotFoundException("Entity '" + id + "' not found."); + // TODO: change when ORM tool available + entityToUpdate.setId(id); + getRepository().save(entityToUpdate); + return entity; + } - @Transactional - public void deleteAllHardDelete() - { - getRepository().deleteAll(); - } + /** + * Deletes all entities from the database without soft-delete functionality. + */ + @Transactional + public void deleteAllHardDelete() { + getRepository().deleteAll(); + } } diff --git a/core/src/main/java/cz/muni/fi/pa165/core/user/UserController.java b/core/src/main/java/cz/muni/fi/pa165/core/user/UserController.java index 56bd7cb344f1f31fcd26ac45a8231eb553a98bd2..317a298869e35105b831a027539e4c17ca7f7d35 100644 --- a/core/src/main/java/cz/muni/fi/pa165/core/user/UserController.java +++ b/core/src/main/java/cz/muni/fi/pa165/core/user/UserController.java @@ -1,10 +1,7 @@ package cz.muni.fi.pa165.core.user; +import cz.muni.fi.pa165.model.dto.user.*; import cz.muni.fi.pa165.model.dto.common.Result; -import cz.muni.fi.pa165.model.dto.user.UserCreateDto; -import cz.muni.fi.pa165.model.dto.user.UserDto; -import cz.muni.fi.pa165.model.dto.user.UserStatisticsCreateDto; -import cz.muni.fi.pa165.model.dto.user.UserUpdateDto; import jakarta.validation.Valid; import org.springframework.beans.factory.annotation.Autowired; import io.swagger.v3.oas.annotations.Operation; @@ -14,6 +11,8 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -39,74 +38,75 @@ public class UserController { this.userService = userService; } - @Operation( - summary = "Find user by ID", - description = "Returns a single user", - tags = {"user"}) - @ApiResponses( - value = { - @ApiResponse( - responseCode = "200", - description = "User found", - content = { - @Content( - mediaType = "application/json", - schema = @Schema(implementation = UserDto.class)) - }), - @ApiResponse(responseCode = "404", description = "User not found", content = @Content) - }) - @GetMapping("/{id}") - public UserDto findById( - @Parameter(description = "ID of user to be searched") @PathVariable String id) { - return userFacade.findById(id); - } - - @Operation( - summary = "Create user", - description = "Creates a new user", - tags = {"user"}) - @ApiResponses( - value = { - @ApiResponse( - responseCode = "201", - description = "User created", - content = { - @Content( - mediaType = "application/json", - schema = @Schema(implementation = UserDto.class)) - }), - @ApiResponse(responseCode = "400", description = "Invalid input", content = @Content), - @ApiResponse( - responseCode = "409", - description = "User with the same name already exists", - content = @Content) - }) - @PostMapping - public UserDto create( - @Parameter(description = "User to be created") @RequestBody UserCreateDto userCreateDto) { - return userFacade.create(userCreateDto); - } - - @Operation( - summary = "Get all users", - description = "Returns all users", - tags = {"user"}) - @ApiResponses( - value = { - @ApiResponse( - responseCode = "200", - description = "Users found", - content = { - @Content( - mediaType = "application/json", - schema = @Schema(implementation = Result.class)) - }) - }) - @GetMapping - public Result<UserDto> findAll( - @Parameter(description = "Page number of results to retrieve") @RequestParam int page) { - return userFacade.findAll(page); - } + @Operation( + summary = "Find user by ID", + description = "Returns a single user", + tags = {"user"}) + @ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "User found", + content = { + @Content( + mediaType = "application/json", + schema = @Schema(implementation = UserDto.class)) + }), + @ApiResponse(responseCode = "404", description = "User not found", content = @Content) + }) + @GetMapping("/{id}") + public UserDto findById( + @Parameter(description = "ID of user to be searched") @PathVariable String id) { + return userFacade.findById(id); + } + + @Operation( + summary = "Create user", + description = "Creates a new user", + tags = {"user"}) + @ApiResponses( + value = { + @ApiResponse( + responseCode = "201", + description = "User created", + content = { + @Content( + mediaType = "application/json", + schema = @Schema(implementation = UserDto.class)) + }), + @ApiResponse(responseCode = "400", description = "Invalid input", content = @Content), + @ApiResponse( + responseCode = "409", + description = "User with the same name already exists", + content = @Content) + }) + @PostMapping + public ResponseEntity<UserDto> create( + @Parameter(description = "User to be created") @RequestBody UserCreateDto userCreateDto) { + return ResponseEntity.status(HttpStatus.CREATED).body(userFacade.create(userCreateDto)); + + } + + @Operation( + summary = "Get all users", + description = "Returns all users", + tags = {"user"}) + @ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "Users found", + content = { + @Content( + mediaType = "application/json", + schema = @Schema(implementation = Result.class)) + }) + }) + @GetMapping + public Result<UserDto> findAll( + @Parameter(description = "Page number of results to retrieve") @RequestParam int page) { + return userFacade.findAll(page); + } @PutMapping("/{id}") @Operation(summary = "Update a user by ID") @@ -166,7 +166,49 @@ public class UserController { return userFacade.findAll(); } + + @Operation(summary = "Log in a user", tags = {"user"}) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successful login"), + @ApiResponse(responseCode = "401", description = "Invalid credentials") + }) + @PostMapping("/login") + public ResponseEntity<?> login(@RequestBody LoginInfoDto request) { + return userFacade.login(request); + } + + @Operation(summary = "Log out a user", tags = {"user"}) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successful logout"), + @ApiResponse(responseCode = "401", description = "Unauthorized") + }) + @PostMapping("/logout") + public ResponseEntity<?> logout() { + return userFacade.logout(); + } + + @PutMapping("/password/{userId}") + @Operation(summary = "Change user password", + description = "Change the password for the specified user.", + tags = {"user"} + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Password changed successfully."), + @ApiResponse(responseCode = "400", description = "Bad request.", content = @Content), + @ApiResponse(responseCode = "401", description = "Unauthorized.", content = @Content), + @ApiResponse(responseCode = "404", description = "User not found.", content = @Content), + @ApiResponse(responseCode = "500", description = "Internal server error.", content = @Content) + }) + public ResponseEntity<?> changePassword( + @Parameter(description = "The ID of the user whose password will be changed.", required = true) + @PathVariable String userId, + @Parameter(description = "The old and new password information.", required = true) + @Valid @RequestBody ChangePasswordDto request) { + return userFacade.changePassword(request, userId); // userId from JWT token. + } /* TODO: get user with roles */ } + + diff --git a/core/src/main/java/cz/muni/fi/pa165/core/user/UserFacade.java b/core/src/main/java/cz/muni/fi/pa165/core/user/UserFacade.java index 7abd7d802a79d6ec5803110a059667f26db9bed8..a31277ef80b5d682a8b8b840312cac87e434cf75 100644 --- a/core/src/main/java/cz/muni/fi/pa165/core/user/UserFacade.java +++ b/core/src/main/java/cz/muni/fi/pa165/core/user/UserFacade.java @@ -1,16 +1,15 @@ package cz.muni.fi.pa165.core.user; import cz.muni.fi.pa165.core.common.DomainFacade; -import cz.muni.fi.pa165.model.dto.user.UserCreateDto; -import cz.muni.fi.pa165.model.dto.user.UserDto; -import cz.muni.fi.pa165.model.dto.user.UserUpdateDto; +import cz.muni.fi.pa165.model.dto.user.*; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; + +import static cz.muni.fi.pa165.core.user.utils.PasswordUtil.isPasswordValid; @Component -@Transactional public class UserFacade extends DomainFacade<User, UserDto, UserCreateDto, UserUpdateDto> { private final UserService userService; // For the "UserService" specific methods @@ -23,4 +22,43 @@ public class UserFacade extends DomainFacade<User, UserDto, UserCreateDto, UserU this.userMapper = userMapper; } + public ResponseEntity<?> logout() { + // validate JWT token... + return ResponseEntity.ok("Logged out successfully."); + } + + public ResponseEntity<?> login(LoginInfoDto request) { + String username = request.getUsername(); + String password = request.getPassword(); + if (username.isBlank()) { + ResponseEntity.badRequest().body("Username is empty"); + } + if (!isPasswordValid(password)) { + ResponseEntity.badRequest().body("password is not valid"); + } + return ResponseEntity.ok("ok!"); + } + + public ResponseEntity<?> changePassword(ChangePasswordDto request, String userId){ + String oldPassword = request.getOldPassword(); + String newPassword = request.getNewPassword(); + String confirmPassword = request.getNewPasswordConfirmation(); + + // Check if new password is valid + + if (!isPasswordValid(newPassword)) { + return ResponseEntity.badRequest().body("New password is invalid."); + } + + // Check if new password matches confirmation password + if (!newPassword.equals(confirmPassword)) { + return ResponseEntity.badRequest().body("New password and confirmation password do not match."); + } + + // TODO: Check if old password matches user's current password + + // TODO: Update user's password in the database + + return ResponseEntity.ok("Password changed successfully."); + } } diff --git a/core/src/main/java/cz/muni/fi/pa165/core/user/utils/PasswordUtil.java b/core/src/main/java/cz/muni/fi/pa165/core/user/utils/PasswordUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..70c55ba9cd940d1b68c4a419eead17206639bd65 --- /dev/null +++ b/core/src/main/java/cz/muni/fi/pa165/core/user/utils/PasswordUtil.java @@ -0,0 +1,26 @@ +package cz.muni.fi.pa165.core.user.utils; + +public class PasswordUtil { + public static boolean isPasswordValid(String password) { + // check for minimum length of 8 characters + if (password.length() < 8) { + return false; + } + + // check for white spaces + if (password.chars().anyMatch(Character::isWhitespace)) { + return false; + } + + // check for at least one uppercase letter + if (!password.matches(".*[A-Z].*")) { + return false; + } + + // check for at least one lowercase letter + if (!password.matches(".*[a-z].*")) { + return false; + } + return true; + } +} diff --git a/core/src/test/java/cz/muni/fi/pa165/core/user/UserControllerTest.java b/core/src/test/java/cz/muni/fi/pa165/core/user/UserControllerTest.java new file mode 100644 index 0000000000000000000000000000000000000000..751cab2a39bff9274c4239364df1ddb9da039aab --- /dev/null +++ b/core/src/test/java/cz/muni/fi/pa165/core/user/UserControllerTest.java @@ -0,0 +1,65 @@ +package cz.muni.fi.pa165.core.user; + +import com.fasterxml.jackson.databind.ObjectMapper; +import cz.muni.fi.pa165.model.dto.user.UserCreateDto; +import cz.muni.fi.pa165.model.dto.user.UserDto; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Integration tests for the {@link UserController} class. + */ +@SpringBootTest +@AutoConfigureMockMvc +public class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + private final static String URL = "/api/user"; + private final static String CONTENT_TYPE = "application/json"; + + /** + * Tests the {@link UserController#create(UserCreateDto)} method with valid input. + * Expects the response status code to be 201 (Created). + */ + @Test + @DisplayName("Create user with valid input") + void createNonExistingUserTest() throws Exception { + // Prepare + UserCreateDto createDto = new UserCreateDto(); + createDto.setUsername("testUser"); + createDto.setPassword("testPassword"); + createDto.setFirstName("John"); + createDto.setLastName("Doe"); + createDto.setEmail("johndoe@test.com"); + + // Execute + String response = mockMvc.perform(post(URL) + .contentType(CONTENT_TYPE) + .content(objectMapper.writeValueAsString(createDto))) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + + // Verify + UserDto userDto = objectMapper.readValue(response, UserDto.class); + assertThat(userDto.getId()).isNotNull(); + assertThat(userDto.getUsername()).isEqualTo(createDto.getUsername()); + assertThat(userDto.getEmail()).isEqualTo(createDto.getEmail()); + assertThat(userDto.getFirstName()).isEqualTo(createDto.getFirstName()); + assertThat(userDto.getLastName()).isEqualTo(createDto.getLastName()); + } +} diff --git a/model/src/main/java/cz/muni/fi/pa165/model/dto/user/ChangePasswordDto.java b/model/src/main/java/cz/muni/fi/pa165/model/dto/user/ChangePasswordDto.java new file mode 100644 index 0000000000000000000000000000000000000000..9eb9fdb213e6689f0162312a1bb040beff9cf598 --- /dev/null +++ b/model/src/main/java/cz/muni/fi/pa165/model/dto/user/ChangePasswordDto.java @@ -0,0 +1,12 @@ +package cz.muni.fi.pa165.model.dto.user; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class ChangePasswordDto { + private String oldPassword; + private String newPassword; + private String newPasswordConfirmation; +} diff --git a/model/src/main/java/cz/muni/fi/pa165/model/dto/user/LoginInfoDto.java b/model/src/main/java/cz/muni/fi/pa165/model/dto/user/LoginInfoDto.java new file mode 100644 index 0000000000000000000000000000000000000000..d0b86d2e8b275f740eded31e9a451964afb7cb40 --- /dev/null +++ b/model/src/main/java/cz/muni/fi/pa165/model/dto/user/LoginInfoDto.java @@ -0,0 +1,11 @@ +package cz.muni.fi.pa165.model.dto.user; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class LoginInfoDto { + private String username; + private String password; +}