diff --git a/src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java b/src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java index 597d733..16e6673 100644 --- a/src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java +++ b/src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java @@ -1,5 +1,6 @@ package de.zendric.app.xpensely_server.controller; +import jakarta.validation.Valid; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -51,7 +52,7 @@ public class AppUserController { } @PostMapping("/createUser") - public ResponseEntity createUser(@RequestBody AppUserCreateRequest userRequest) { + public ResponseEntity createUser(@RequestBody @Valid AppUserCreateRequest userRequest) { try { AppUser convertedUser = userRequest.convertToAppUser(); diff --git a/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java b/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java index efaa067..cb5fb13 100644 --- a/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java +++ b/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java @@ -1,30 +1,18 @@ package de.zendric.app.xpensely_server.controller; import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.List; import java.util.Optional; -import org.springframework.beans.factory.annotation.Autowired; +import jakarta.validation.Valid; 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; -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.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; -import de.zendric.app.xpensely_server.model.AppUser; -import de.zendric.app.xpensely_server.model.Expense; -import de.zendric.app.xpensely_server.model.ExpenseChangeRequest; -import de.zendric.app.xpensely_server.model.ExpenseInput; -import de.zendric.app.xpensely_server.model.ExpenseList; -import de.zendric.app.xpensely_server.model.InviteRequest; -import de.zendric.app.xpensely_server.model.XpenselyStandardCategories; +import de.zendric.app.xpensely_server.model.*; +import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; import de.zendric.app.xpensely_server.services.CategoryService; import de.zendric.app.xpensely_server.services.ExpenseListService; import de.zendric.app.xpensely_server.services.UserService; @@ -33,93 +21,51 @@ import de.zendric.app.xpensely_server.services.UserService; @RequestMapping("/api/expenselist") class ExpenseListController { - private ExpenseListService expenseListService; - private UserService userService; - private CategoryService categoryService; + private final ExpenseListService expenseListService; + private final UserService userService; + private final CategoryService categoryService; + private final AuthenticatedUserResolver authenticatedUserResolver; - @Autowired public ExpenseListController(ExpenseListService expenseListService, UserService userService, - CategoryService categoryService) { + CategoryService categoryService, AuthenticatedUserResolver authenticatedUserResolver) { this.expenseListService = expenseListService; this.userService = userService; this.categoryService = categoryService; + this.authenticatedUserResolver = authenticatedUserResolver; } - @GetMapping("/all") - public ResponseEntity> getAll() { - try { - List items = new ArrayList<>(); - - expenseListService.findAll().forEach(items::add); - - if (items.isEmpty()) - return new ResponseEntity<>(HttpStatus.NO_CONTENT); - - return new ResponseEntity<>(items, HttpStatus.OK); - } catch (Exception e) { - return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR); - } - } - - @GetMapping("/byUser") - public ResponseEntity> getByUser(@RequestParam Long userId) { - try { - List items = expenseListService.findByUserId(userId); - - if (items.isEmpty()) - return new ResponseEntity<>(HttpStatus.NO_CONTENT); - - return new ResponseEntity<>(items, HttpStatus.OK); - } catch (Exception e) { - return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR); - } - } - - @GetMapping("/byUsername") - public ResponseEntity> getByUser(@RequestParam String username) { - try { - List items = expenseListService.findByUsername(username); - - if (items.isEmpty()) - return new ResponseEntity<>(HttpStatus.NO_CONTENT); - - return new ResponseEntity<>(items, HttpStatus.OK); - } catch (Exception e) { - return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR); - } + @GetMapping("/mine") + public ResponseEntity> getMine(Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + List items = expenseListService.findByUserId(user.getId()); + if (items.isEmpty()) + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + return new ResponseEntity<>(items, HttpStatus.OK); } @GetMapping("/byId") - public ResponseEntity getById(@RequestParam Long id) { + public ResponseEntity getById(@RequestParam Long id, Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); Optional existingItemOptional = expenseListService.findById(id); - - if (existingItemOptional.isPresent()) { - return new ResponseEntity<>(existingItemOptional.get(), HttpStatus.OK); - } else { + if (existingItemOptional.isEmpty()) return new ResponseEntity<>(HttpStatus.NOT_FOUND); - } + assertMember(user, existingItemOptional.get()); + return new ResponseEntity<>(existingItemOptional.get(), HttpStatus.OK); } @PostMapping("/create") - // TODO add handling of categories by using DTO - public ResponseEntity create(@RequestBody ExpenseList expenseList) { + public ResponseEntity create(@RequestBody ExpenseList expenseList, + Authentication authentication) { try { - if (expenseList.getOwner() != null) { - AppUser existingOwner = userService.getUser(expenseList.getOwner().getId()); - if (existingOwner == null) { - throw new IllegalArgumentException("Owner does not exist."); - } - expenseList.setOwner(existingOwner); - XpenselyStandardCategories standardCategories = categoryService.getDefaultCategories(); - expenseList.setXpenselyStandardCategories(standardCategories); - } else { - throw new IllegalArgumentException("Owner is required."); - } - + AppUser authenticatedUser = authenticatedUserResolver.resolveCurrentUser(authentication); + expenseList.setOwner(authenticatedUser); + XpenselyStandardCategories standardCategories = categoryService.getDefaultCategories(); + expenseList.setXpenselyStandardCategories(standardCategories); expenseList.setSharedWith(null); - ExpenseList savedItem = expenseListService.createList(expenseList); return new ResponseEntity<>(savedItem, HttpStatus.CREATED); + } catch (ResponseStatusException e) { + throw e; } catch (Exception e) { e.printStackTrace(); return new ResponseEntity<>(null, HttpStatus.EXPECTATION_FAILED); @@ -127,7 +73,12 @@ class ExpenseListController { } @DeleteMapping("{id}") - public ResponseEntity delete(@PathVariable("id") Long id) { + public ResponseEntity delete(@PathVariable("id") Long id, Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + Optional listOpt = expenseListService.findById(id); + if (listOpt.isEmpty()) + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + assertOwner(user, listOpt.get()); try { expenseListService.deleteById(id); return new ResponseEntity<>(HttpStatus.NO_CONTENT); @@ -139,11 +90,16 @@ class ExpenseListController { @PostMapping("/{id}/add") public ResponseEntity addExpenseToList( @PathVariable("id") Long expenseListId, - @RequestBody ExpenseInput expenseInput) { + @RequestBody @Valid ExpenseInput expenseInput, + Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + Optional listOpt = expenseListService.findById(expenseListId); + if (listOpt.isEmpty()) + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + assertMember(user, listOpt.get()); try { AppUser expenseOwner = userService.getUserByName(expenseInput.getOwner()); Expense expense = expenseInput.convertToExpense(expenseOwner.getId()); - Expense addedExpense = expenseListService.addExpenseToList(expenseListId, expense); return new ResponseEntity<>(addedExpense, HttpStatus.CREATED); } catch (Exception e) { @@ -154,18 +110,18 @@ class ExpenseListController { @PutMapping("/{id}/update") public ResponseEntity updateExpenseInList( @PathVariable("id") Long expenseListId, - @RequestBody ExpenseChangeRequest expenseChangeRequest) { + @RequestBody @Valid ExpenseChangeRequest expenseChangeRequest, + Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + Optional expenseListOpt = expenseListService.findById(expenseListId); + if (expenseListOpt.isEmpty()) + return new ResponseEntity<>(null, HttpStatus.NOT_FOUND); + assertMember(user, expenseListOpt.get()); try { AppUser expenseOwner = userService.getUserByName(expenseChangeRequest.getOwnerName()); - Optional expenseList = expenseListService.findById(expenseListId); - if (expenseList.isPresent()) { - Expense expense = expenseChangeRequest.convertToExpense(expenseOwner.getId(), expenseList.get()); - - Expense addedExpense = expenseListService.updateExpense(expenseListId, expense); - return new ResponseEntity<>(addedExpense, HttpStatus.CREATED); - } - return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST); - + Expense expense = expenseChangeRequest.convertToExpense(expenseOwner.getId(), expenseListOpt.get()); + Expense updatedExpense = expenseListService.updateExpense(expenseListId, expense); + return new ResponseEntity<>(updatedExpense, HttpStatus.OK); } catch (Exception e) { return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST); } @@ -174,7 +130,13 @@ class ExpenseListController { @DeleteMapping("/{id}/delete") public ResponseEntity deleteExpenseFromList( @PathVariable("id") Long expenseListId, - @RequestParam("expenseId") Long expenseId) { + @RequestParam("expenseId") Long expenseId, + Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + Optional listOpt = expenseListService.findById(expenseListId); + if (listOpt.isEmpty()) + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + assertMember(user, listOpt.get()); try { expenseListService.deleteExpenseFromList(expenseListId, expenseId); return new ResponseEntity<>(HttpStatus.NO_CONTENT); @@ -184,13 +146,20 @@ class ExpenseListController { } @PostMapping("/{listId}/invite") - public ResponseEntity generateInvite(@PathVariable Long listId) { + public ResponseEntity generateInvite(@PathVariable Long listId, Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + Optional listOpt = expenseListService.findById(listId); + if (listOpt.isEmpty()) + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + assertOwner(user, listOpt.get()); String inviteCode = expenseListService.generateInviteCode(listId); return ResponseEntity.ok(inviteCode); } @PostMapping("/accept-invite") - public ResponseEntity acceptInvite(@RequestBody InviteRequest inviteRequest) { + public ResponseEntity acceptInvite(@RequestBody @Valid InviteRequest inviteRequest, + Authentication authentication) { + AppUser authenticatedUser = authenticatedUserResolver.resolveCurrentUser(authentication); ExpenseList list = expenseListService.findByInviteCode(inviteRequest.getInviteCode()); if (list == null || list.getInviteCodeExpiration() == null || @@ -200,21 +169,24 @@ class ExpenseListController { if (list.getSharedWith() != null) { return ResponseEntity.status(HttpStatus.IM_USED).body("List has already been shared"); } - if (list.getOwner().getId() == inviteRequest.getUserId()) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("You cant join your own List"); - } - AppUser user = null; - try { - user = userService.getUser(inviteRequest.getUserId()); - } catch (Exception e) { - throw new RuntimeException("User not found"); - } - if (user != null) { - list.setSharedWith(user); - expenseListService.save(list); - } else { - throw new RuntimeException("User not found"); + if (list.getOwner().getId().equals(authenticatedUser.getId())) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("You cannot join your own list"); } + list.setSharedWith(authenticatedUser); + expenseListService.save(list); return ResponseEntity.ok("User added to the list"); } + + private void assertOwner(AppUser authenticated, ExpenseList list) { + if (!list.getOwner().getId().equals(authenticated.getId())) + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } + + private void assertMember(AppUser authenticated, ExpenseList list) { + boolean isOwner = list.getOwner().getId().equals(authenticated.getId()); + boolean isShared = list.getSharedWith() != null + && list.getSharedWith().getId().equals(authenticated.getId()); + if (!isOwner && !isShared) + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } } diff --git a/src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java b/src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java new file mode 100644 index 0000000..efbc72d --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java @@ -0,0 +1,30 @@ +package de.zendric.app.xpensely_server.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationErrors(MethodArgumentNotValidException ex) { + Map errors = new HashMap<>(); + for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) { + errors.put(fieldError.getField(), fieldError.getDefaultMessage()); + } + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument(IllegalArgumentException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", ex.getMessage())); + } +} diff --git a/src/main/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolver.java b/src/main/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolver.java new file mode 100644 index 0000000..4d5686c --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolver.java @@ -0,0 +1,36 @@ +package de.zendric.app.xpensely_server.security; + +import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.services.UserService; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +@Component +public class AuthenticatedUserResolver { + + private final UserService userService; + + public AuthenticatedUserResolver(UserService userService) { + this.userService = userService; + } + + public AppUser resolveCurrentUser(Authentication authentication) { + if (authentication == null) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not authenticated"); + } + Jwt jwt = (Jwt) authentication.getPrincipal(); + String googleId = jwt.getSubject(); + try { + AppUser user = userService.getUserByGoogleId(googleId); + if (user == null) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "User not registered"); + } + return user; + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "User not registered"); + } + } +} diff --git a/src/test/java/de/zendric/app/xpensely_Server/controller/AppUserControllerTest.java b/src/test/java/de/zendric/app/xpensely_Server/controller/AppUserControllerTest.java new file mode 100644 index 0000000..7e2167a --- /dev/null +++ b/src/test/java/de/zendric/app/xpensely_Server/controller/AppUserControllerTest.java @@ -0,0 +1,60 @@ +package de.zendric.app.xpensely_Server.controller; + +import de.zendric.app.xpensely_server.controller.AppUserController; +import de.zendric.app.xpensely_server.services.UserService; +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.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(AppUserController.class) +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +class AppUserControllerTest { + + @Autowired MockMvc mockMvc; + @MockitoBean UserService userService; + + @Test + void createUser_blankUsername_returns400WithFieldError() throws Exception { + mockMvc.perform(post("/api/users/createUser") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"\",\"googleId\":\"gid123\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.username").exists()); + } + + @Test + void createUser_invalidUsernamePattern_returns400() throws Exception { + mockMvc.perform(post("/api/users/createUser") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"hello world!\",\"googleId\":\"gid123\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.username").exists()); + } + + @Test + void createUser_usernameTooShort_returns400() throws Exception { + mockMvc.perform(post("/api/users/createUser") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"ab\",\"googleId\":\"gid123\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.username").exists()); + } + + @Test + void createUser_blankGoogleId_returns400() throws Exception { + mockMvc.perform(post("/api/users/createUser") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"validuser\",\"googleId\":\"\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.googleId").exists()); + } +}