From 19c7e1915f5b620bfcc7986f593c856505bce6aa Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Tue, 5 May 2026 17:13:53 +0200 Subject: [PATCH] security hardening (#12) Reviewed-on: https://tea.zendric.de/Cedric/XpenselyServer/pulls/12 Co-authored-by: Cedric Hornberger Co-committed-by: Cedric Hornberger --- .gitignore | 1 + pom.xml | 16 +- .../controller/AppUserController.java | 56 ++--- .../controller/ExpenseListController.java | 206 ++++++++---------- .../controller/GlobalExceptionHandler.java | 30 +++ .../model/AppUserCreateRequest.java | 10 +- .../Exception/ResourceNotFoundException.java | 11 + .../model/ExpenseChangeRequest.java | 19 +- .../xpensely_server/model/ExpenseInput.java | 18 +- .../xpensely_server/model/InviteRequest.java | 6 +- .../repo/ExpenseListRepository.java | 11 +- .../security/AuthenticatedUserResolver.java | 36 +++ .../security/RateLimitFilter.java | 61 ++++++ .../security/SecurityConfig.java | 2 + .../services/ExpenseListService.java | 81 ++----- .../xpensely_server/services/UserService.java | 36 +-- .../ExpenseListRepositoryTest.java | 47 ++-- .../controller/AppUserControllerTest.java | 95 ++++++++ .../controller/ExpenseListControllerTest.java | 135 ++++++++++++ .../AuthenticatedUserResolverTest.java | 78 +++++++ .../security/RateLimitFilterTest.java | 89 ++++++++ .../services/ExpenseListServiceTest.java | 57 +++++ .../services/UserServiceTest.java | 75 +++++++ 23 files changed, 916 insertions(+), 260 deletions(-) create mode 100644 src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java create mode 100644 src/main/java/de/zendric/app/xpensely_server/model/Exception/ResourceNotFoundException.java create mode 100644 src/main/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolver.java create mode 100644 src/main/java/de/zendric/app/xpensely_server/security/RateLimitFilter.java create mode 100644 src/test/java/de/zendric/app/xpensely_Server/controller/AppUserControllerTest.java create mode 100644 src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java create mode 100644 src/test/java/de/zendric/app/xpensely_Server/security/AuthenticatedUserResolverTest.java create mode 100644 src/test/java/de/zendric/app/xpensely_Server/security/RateLimitFilterTest.java create mode 100644 src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java create mode 100644 src/test/java/de/zendric/app/xpensely_Server/services/UserServiceTest.java diff --git a/.gitignore b/.gitignore index 37fdfda..8c660d3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ HELP.md target/ +/docs/superpowers !.mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ !**/src/test/**/target/ diff --git a/pom.xml b/pom.xml index df740a9..83dc34c 100644 --- a/pom.xml +++ b/pom.xml @@ -27,7 +27,7 @@ - 17 + 21 @@ -38,6 +38,15 @@ org.springframework.boot spring-boot-starter-security + + org.springframework.boot + spring-boot-starter-validation + + + com.bucket4j + bucket4j-core + 8.10.1 + org.springframework.boot spring-boot-starter-oauth2-resource-server @@ -71,6 +80,11 @@ spring-boot-starter-test test + + com.h2database + h2 + test + org.springframework.security spring-security-test 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..7bb5e8a 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,35 +1,35 @@ package de.zendric.app.xpensely_server.controller; -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.PostMapping; -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.AppUserCreateRequest; import de.zendric.app.xpensely_server.model.Exception.UsernameAlreadyExistsException; +import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; import de.zendric.app.xpensely_server.services.UserService; @RestController @RequestMapping("/api/users") public class AppUserController { - private UserService userService; + private final UserService userService; + private final AuthenticatedUserResolver authenticatedUserResolver; - @Autowired - public AppUserController(UserService userService) { + public AppUserController(UserService userService, AuthenticatedUserResolver authenticatedUserResolver) { this.userService = userService; + this.authenticatedUserResolver = authenticatedUserResolver; } @GetMapping - public AppUser getUser(@RequestParam Long id) { - return userService.getUser(id); + public ResponseEntity getUser(@RequestParam Long id, Authentication authentication) { + AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication); + assertSelf(self, id); + return ResponseEntity.ok(userService.getUser(id)); } @GetMapping("/byName") @@ -38,23 +38,17 @@ public class AppUserController { } @GetMapping("/byGoogleId") - public ResponseEntity getUserByGoogleId(@RequestParam String id) { - try { - AppUser userByGoogleId = userService.getUserByGoogleId(id); - return new ResponseEntity<>(userByGoogleId, HttpStatus.OK); - - } catch (IllegalArgumentException e) { - return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST); - } catch (Exception e) { - return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR); - } + public ResponseEntity getUserByGoogleId(@RequestParam String id, Authentication authentication) { + AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication); + if (!self.getGoogleId().equals(id)) + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + return ResponseEntity.ok(self); } @PostMapping("/createUser") - public ResponseEntity createUser(@RequestBody AppUserCreateRequest userRequest) { + public ResponseEntity createUser(@RequestBody @Valid AppUserCreateRequest userRequest) { try { AppUser convertedUser = userRequest.convertToAppUser(); - AppUser nUser = userService.createUser(convertedUser); return new ResponseEntity<>(nUser, HttpStatus.CREATED); } catch (UsernameAlreadyExistsException e) { @@ -62,12 +56,18 @@ public class AppUserController { } catch (Exception e) { return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST); } - } @DeleteMapping - public String deleteUser(@RequestParam Long id) { + public ResponseEntity deleteUser(@RequestParam Long id, Authentication authentication) { + AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication); + assertSelf(self, id); AppUser user = userService.deleteUserById(id); - return "User deleted : " + user.getUsername(); + return ResponseEntity.ok("User deleted: " + user.getUsername()); + } + + private void assertSelf(AppUser authenticated, Long requestedId) { + if (!authenticated.getId().equals(requestedId)) + throw new ResponseStatusException(HttpStatus.FORBIDDEN); } } 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..7d86cf7 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,125 +1,71 @@ 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; @RestController @RequestMapping("/api/expenselist") -class ExpenseListController { +public 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/model/AppUserCreateRequest.java b/src/main/java/de/zendric/app/xpensely_server/model/AppUserCreateRequest.java index 3ae678d..c2f05ed 100644 --- a/src/main/java/de/zendric/app/xpensely_server/model/AppUserCreateRequest.java +++ b/src/main/java/de/zendric/app/xpensely_server/model/AppUserCreateRequest.java @@ -1,21 +1,25 @@ package de.zendric.app.xpensely_server.model; -import jakarta.persistence.Column; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import lombok.Data; @Data public class AppUserCreateRequest { - @Column(name = "username", nullable = false, unique = true) + @NotBlank(message = "Username is required") + @Size(min = 3, max = 30, message = "Username must be between 3 and 30 characters") + @Pattern(regexp = "^[a-zA-Z0-9_.\\-]+$", message = "Username may only contain letters, digits, underscores, dots, and hyphens") private String username; + @NotBlank(message = "Google ID is required") private String googleId; public AppUser convertToAppUser() { AppUser appUser = new AppUser(); appUser.setGoogleId(googleId); appUser.setUsername(username); - return appUser; } } diff --git a/src/main/java/de/zendric/app/xpensely_server/model/Exception/ResourceNotFoundException.java b/src/main/java/de/zendric/app/xpensely_server/model/Exception/ResourceNotFoundException.java new file mode 100644 index 0000000..46e0582 --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/model/Exception/ResourceNotFoundException.java @@ -0,0 +1,11 @@ +package de.zendric.app.xpensely_server.model.Exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.NOT_FOUND) +public class ResourceNotFoundException extends RuntimeException { + public ResourceNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java index 65f8edd..20c161b 100644 --- a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java +++ b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java @@ -2,6 +2,10 @@ package de.zendric.app.xpensely_server.model; import java.time.LocalDate; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -12,12 +16,25 @@ import lombok.NoArgsConstructor; public class ExpenseChangeRequest { private Long id; + + @NotBlank(message = "Title is required") + @Size(max = 100, message = "Title must not exceed 100 characters") private String title; + + @NotBlank(message = "Owner name is required") private String ownerName; + + @NotNull(message = "Amount is required") + @DecimalMin(value = "0.01", message = "Amount must be greater than zero") private Double amount; + private Double personalUseAmount; private Double otherPersonAmount; + + @NotNull(message = "Date is required") private LocalDate date; + + @NotBlank(message = "Category is required") private String category; public Expense convertToExpense(Long userId, ExpenseList expenseList) { @@ -38,4 +55,4 @@ public class ExpenseChangeRequest { return expense; } -} \ No newline at end of file +} diff --git a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java index 3fb17ad..1a31fd2 100644 --- a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java +++ b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java @@ -2,9 +2,10 @@ package de.zendric.app.xpensely_server.model; import java.time.LocalDate; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -16,19 +17,26 @@ import lombok.Setter; @NoArgsConstructor public class ExpenseInput { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @NotBlank(message = "Title is required") + @Size(max = 100, message = "Title must not exceed 100 characters") private String title; + @NotBlank(message = "Owner is required") private String owner; + @NotNull(message = "Amount is required") + @DecimalMin(value = "0.01", message = "Amount must be greater than zero") private Double amount; + private Double personalUseAmount; private Double otherPersonAmount; + @NotNull(message = "Date is required") private LocalDate date; + + @NotBlank(message = "Category is required") private String category; private ExpenseList expenseList; diff --git a/src/main/java/de/zendric/app/xpensely_server/model/InviteRequest.java b/src/main/java/de/zendric/app/xpensely_server/model/InviteRequest.java index 81ac35a..e572c44 100644 --- a/src/main/java/de/zendric/app/xpensely_server/model/InviteRequest.java +++ b/src/main/java/de/zendric/app/xpensely_server/model/InviteRequest.java @@ -1,5 +1,7 @@ package de.zendric.app.xpensely_server.model; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -8,6 +10,8 @@ import lombok.NoArgsConstructor; @AllArgsConstructor @NoArgsConstructor public class InviteRequest { + + @NotBlank(message = "Invite code is required") + @Size(min = 6, max = 6, message = "Invite code must be exactly 6 characters") private String inviteCode; - private Long userId; } diff --git a/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseListRepository.java b/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseListRepository.java index c42b820..6c8ccfb 100644 --- a/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseListRepository.java +++ b/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseListRepository.java @@ -3,13 +3,22 @@ package de.zendric.app.xpensely_server.repo; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import de.zendric.app.xpensely_server.model.ExpenseList; @Repository public interface ExpenseListRepository extends JpaRepository { + List findByOwnerId(Long ownerId); ExpenseList findByInviteCode(String inviteCode); -} \ No newline at end of file + + @Query("SELECT el FROM ExpenseList el WHERE el.owner.id = :userId OR el.sharedWith.id = :userId") + List findByOwnerIdOrSharedWithId(@Param("userId") Long userId); + + @Query("SELECT el FROM ExpenseList el WHERE el.owner.username = :username OR el.sharedWith.username = :username") + List findByOwnerUsernameOrSharedWithUsername(@Param("username") String username); +} 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/main/java/de/zendric/app/xpensely_server/security/RateLimitFilter.java b/src/main/java/de/zendric/app/xpensely_server/security/RateLimitFilter.java new file mode 100644 index 0000000..f04341e --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/security/RateLimitFilter.java @@ -0,0 +1,61 @@ +package de.zendric.app.xpensely_server.security; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class RateLimitFilter extends OncePerRequestFilter { + + private static final int REQUESTS_PER_MINUTE = 60; + + private final Map buckets = new ConcurrentHashMap<>(); + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String key = resolveKey(request); + Bucket bucket = buckets.computeIfAbsent(key, k -> newBucket()); + + if (bucket.tryConsume(1)) { + filterChain.doFilter(request, response); + } else { + response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + response.getWriter().write("Rate limit exceeded"); + } + } + + private String resolveKey(HttpServletRequest request) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getPrincipal() instanceof Jwt jwt) { + return "user:" + jwt.getSubject(); + } + String ip = request.getHeader("X-Forwarded-For"); + if (ip != null && !ip.isBlank()) { + return "ip:" + ip.split(",")[0].trim(); + } + return "ip:" + request.getRemoteAddr(); + } + + private Bucket newBucket() { + return Bucket.builder() + .addLimit(Bandwidth.builder() + .capacity(REQUESTS_PER_MINUTE) + .refillGreedy(REQUESTS_PER_MINUTE, Duration.ofMinutes(1)) + .build()) + .build(); + } +} diff --git a/src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java b/src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java index d0d2465..1ae17fc 100644 --- a/src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java +++ b/src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java @@ -6,6 +6,7 @@ import org.springframework.context.annotation.Profile; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter; import org.springframework.security.web.SecurityFilterChain; @Configuration @@ -31,6 +32,7 @@ public class SecurityConfig { .oauth2ResourceServer(oauth2 -> oauth2 .jwt(Customizer.withDefaults())) .oauth2Login(Customizer.withDefaults()) + .addFilterAfter(new RateLimitFilter(), BearerTokenAuthenticationFilter.class) .csrf().disable(); return http.build(); diff --git a/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java b/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java index 0ee1be6..0f65b6e 100644 --- a/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java +++ b/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java @@ -1,37 +1,29 @@ package de.zendric.app.xpensely_server.services; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.UUID; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import de.zendric.app.xpensely_server.model.AppUser; import de.zendric.app.xpensely_server.model.Expense; import de.zendric.app.xpensely_server.model.ExpenseList; +import de.zendric.app.xpensely_server.model.Exception.ResourceNotFoundException; import de.zendric.app.xpensely_server.model.XpenselyCustomCategory; import de.zendric.app.xpensely_server.repo.ExpenseListRepository; import de.zendric.app.xpensely_server.repo.ExpenseRepository; import de.zendric.app.xpensely_server.repo.XpenselyCustomCategoryRepository; -import jakarta.persistence.EntityManager; @Service @Transactional public class ExpenseListService { - private ExpenseListRepository repository; + private final ExpenseListRepository repository; private final ExpenseRepository expenseRepository; - private XpenselyCustomCategoryRepository customCategoryRepository; + private final XpenselyCustomCategoryRepository customCategoryRepository; - @Autowired - private EntityManager entityManager; - - @Autowired public ExpenseListService(ExpenseListRepository repository, ExpenseRepository expenseRepository, XpenselyCustomCategoryRepository customCategoryRepository) { this.repository = repository; @@ -68,67 +60,24 @@ public class ExpenseListService { } public List findByUserId(Long id) { - List allLists = repository.findAll(); - List userSpecificList = new ArrayList<>(); - for (ExpenseList expenseList : allLists) { - AppUser sharedWith = expenseList.getSharedWith(); - - if (expenseList.getOwner().getId().equals(id)) { - userSpecificList.add(expenseList); - } else { - if (sharedWith != null && sharedWith.getId().equals(id)) { - userSpecificList.add(expenseList); - } - } - } - return userSpecificList; + return repository.findByOwnerIdOrSharedWithId(id); } public List findByUsername(String username) { - List allLists = repository.findAll(); - List userSpecificList = new ArrayList<>(); - for (ExpenseList expenseList : allLists) { - AppUser sharedWith = expenseList.getSharedWith(); - - if (expenseList.getOwner().getUsername().equals(username)) { - userSpecificList.add(expenseList); - } else { - if (sharedWith != null && sharedWith.getUsername().equals(username)) { - userSpecificList.add(expenseList); - } - } - } - return userSpecificList; - + return repository.findByOwnerUsernameOrSharedWithUsername(username); } public Expense addExpenseToList(Long expenseListId, Expense expense) { - // find expenseList ExpenseList expenseList = repository.findById(expenseListId) - .orElseThrow(() -> new RuntimeException("ExpenseList not found with id: " + expenseListId)); - // get all added expenses - HashSet existingId = new HashSet<>(); - for (Expense e : expenseList.getExpenses()) { - existingId.add(e.getId()); - } - // add the new expense + .orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + expenseListId)); expenseList.addExpense(expense); - // save repository.save(expenseList); - - Expense newExpense = new Expense(); - for (Expense e : expenseList.getExpenses()) { - if (!existingId.contains(e.getId())) { - newExpense = e; - break; - } - } - return newExpense; + return expense; } public void deleteExpenseFromList(Long expenseListId, Long expenseId) { ExpenseList expenseList = repository.findById(expenseListId) - .orElseThrow(() -> new RuntimeException("ExpenseList not found with id: " + expenseListId)); + .orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + expenseListId)); Expense expenseToRemove = null; for (Expense expense : expenseList.getExpenses()) { if (expense.getId().equals(expenseId)) { @@ -139,14 +88,14 @@ public class ExpenseListService { if (expenseToRemove != null) { expenseList.removeExpense(expenseToRemove); } else { - throw new RuntimeException("Expense not found with id: " + expenseId); + throw new ResourceNotFoundException("Expense not found with id: " + expenseId); } repository.save(expenseList); } public String generateInviteCode(Long listId) { ExpenseList list = repository.findById(listId) - .orElseThrow(() -> new RuntimeException("List not found")); + .orElseThrow(() -> new ResourceNotFoundException("List not found")); String inviteCode; if (list.getInviteCode() == null || list.getInviteCodeExpiration().isBefore(LocalDateTime.now())) { @@ -168,7 +117,7 @@ public class ExpenseListService { public Expense updateExpense(Long expenseListId, Expense updatedExpense) { ExpenseList expenseList = repository.findById(expenseListId) - .orElseThrow(() -> new IllegalArgumentException("ExpenseList not found")); + .orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + expenseListId)); if (!expenseList.getExpenses().stream() .anyMatch(expense -> expense.getId().equals(updatedExpense.getId()))) { @@ -176,7 +125,7 @@ public class ExpenseListService { } Expense existingExpense = expenseRepository.findById(updatedExpense.getId()) - .orElseThrow(() -> new IllegalArgumentException("Expense not found")); + .orElseThrow(() -> new ResourceNotFoundException("Expense not found with id: " + updatedExpense.getId())); existingExpense.setTitle(updatedExpense.getTitle()); existingExpense.setAmount(updatedExpense.getAmount()); existingExpense.setPersonalUseAmount(updatedExpense.getPersonalUseAmount()); @@ -191,7 +140,7 @@ public class ExpenseListService { // TODO implement API for this public XpenselyCustomCategory addCustomCategory(Long expenseListId, XpenselyCustomCategory customCategory) { ExpenseList expenseList = repository.findById(expenseListId) - .orElseThrow(() -> new RuntimeException("Expense List not found")); + .orElseThrow(() -> new ResourceNotFoundException("Expense List not found")); customCategory.setExpenseList(expenseList); return customCategoryRepository.save(customCategory); @@ -200,9 +149,9 @@ public class ExpenseListService { // TODO implement API for this public void deleteCustomCategory(Long expenseListId, Long categoryId) { XpenselyCustomCategory category = customCategoryRepository.findById(categoryId) - .orElseThrow(() -> new RuntimeException("Custom Category not found")); + .orElseThrow(() -> new ResourceNotFoundException("Custom Category not found")); if (!category.getExpenseList().getId().equals(expenseListId)) { - throw new RuntimeException("Category does not belong to the specified Expense List"); + throw new IllegalArgumentException("Category does not belong to the specified Expense List"); } customCategoryRepository.delete(category); } diff --git a/src/main/java/de/zendric/app/xpensely_server/services/UserService.java b/src/main/java/de/zendric/app/xpensely_server/services/UserService.java index dc0dee7..a86d665 100644 --- a/src/main/java/de/zendric/app/xpensely_server/services/UserService.java +++ b/src/main/java/de/zendric/app/xpensely_server/services/UserService.java @@ -1,11 +1,11 @@ package de.zendric.app.xpensely_server.services; import java.util.List; -import java.util.Optional; import org.springframework.stereotype.Service; import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.model.Exception.ResourceNotFoundException; import de.zendric.app.xpensely_server.model.Exception.UsernameAlreadyExistsException; import de.zendric.app.xpensely_server.repo.UserRepository; @@ -29,36 +29,24 @@ public class UserService { } public AppUser getUser(Long id) { - Optional user = userRepository.findById(id); - if (user.isPresent()) { - return user.get(); - } else - return null; + return userRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + id)); } public AppUser deleteUserById(Long id) { - Optional user = userRepository.findById(id); - if (user.isPresent()) { - userRepository.deleteById(id); - return user.get(); - } else - return null; + AppUser user = userRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + id)); + userRepository.deleteById(id); + return user; } public AppUser getUserByName(String username) { - Optional optUser = userRepository.findByUsername(username); - if (optUser.isPresent()) { - return optUser.get(); - } else - return null; + return userRepository.findByUsername(username) + .orElseThrow(() -> new ResourceNotFoundException("User not found: " + username)); } public AppUser getUserByGoogleId(String id) { - Optional optUser = userRepository.findByGoogleId(id); - if (optUser.isPresent()) { - return optUser.get(); - } else - return null; + return userRepository.findByGoogleId(id) + .orElseThrow(() -> new ResourceNotFoundException("User not found with Google ID: " + id)); } - -} \ No newline at end of file +} diff --git a/src/test/java/de/zendric/app/xpensely_Server/ExpenseListRepositoryTest.java b/src/test/java/de/zendric/app/xpensely_Server/ExpenseListRepositoryTest.java index 6a4cb49..23bce57 100644 --- a/src/test/java/de/zendric/app/xpensely_Server/ExpenseListRepositoryTest.java +++ b/src/test/java/de/zendric/app/xpensely_Server/ExpenseListRepositoryTest.java @@ -1,26 +1,47 @@ package de.zendric.app.xpensely_Server; -import java.util.Optional; - +import de.zendric.app.xpensely_server.model.ExpenseList; +import de.zendric.app.xpensely_server.repo.ExpenseListRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import de.zendric.app.xpensely_server.model.ExpenseList; -import de.zendric.app.xpensely_server.repo.ExpenseListRepository; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; @DataJpaTest class ExpenseListRepositoryTest { - @Autowired - private ExpenseListRepository expenseListRepository; + @Autowired + private ExpenseListRepository expenseListRepository; - @Test - void testFindExpenseListById() { - // Assuming an ExpenseList with id = 1 exists in your test DB. - Optional optionalExpenseList = expenseListRepository.findById(1L); + @Test + void saveAndFindById_returnsExpenseList() { + ExpenseList list = new ExpenseList(); + list.setName("Groceries"); + ExpenseList saved = expenseListRepository.save(list); - ExpenseList expenseList = optionalExpenseList.get(); - System.out.println("ExpenseList name: " + expenseList.getName()); - } + Optional found = expenseListRepository.findById(saved.getId()); + + assertTrue(found.isPresent()); + assertEquals("Groceries", found.get().getName()); + } + + @Test + void findById_nonExistentId_returnsEmpty() { + Optional found = expenseListRepository.findById(999L); + assertTrue(found.isEmpty()); + } + + @Test + void delete_removesFromRepository() { + ExpenseList list = new ExpenseList(); + list.setName("To Delete"); + ExpenseList saved = expenseListRepository.save(list); + + expenseListRepository.deleteById(saved.getId()); + + assertTrue(expenseListRepository.findById(saved.getId()).isEmpty()); + } } 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..94dbd6d --- /dev/null +++ b/src/test/java/de/zendric/app/xpensely_Server/controller/AppUserControllerTest.java @@ -0,0 +1,95 @@ +package de.zendric.app.xpensely_Server.controller; + +import de.zendric.app.xpensely_server.controller.AppUserController; +import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; +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.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +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; + @MockitoBean AuthenticatedUserResolver authenticatedUserResolver; + + @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()); + } + + // --- Authorization tests --- + + @Test + void getUser_differentUser_returns403() throws Exception { + AppUser self = new AppUser(); self.setId(1L); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(self); + + mockMvc.perform(get("/api/users").param("id", "99")) + .andExpect(status().isForbidden()); + } + + @Test + void getUser_sameUser_returns200() throws Exception { + AppUser self = new AppUser(); self.setId(1L); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(self); + when(userService.getUser(1L)).thenReturn(self); + + mockMvc.perform(get("/api/users").param("id", "1")) + .andExpect(status().isOk()); + } + + @Test + void deleteUser_differentUser_returns403() throws Exception { + AppUser self = new AppUser(); self.setId(1L); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(self); + + mockMvc.perform(delete("/api/users").param("id", "99")) + .andExpect(status().isForbidden()); + } +} diff --git a/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java b/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java new file mode 100644 index 0000000..2cba572 --- /dev/null +++ b/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java @@ -0,0 +1,135 @@ +package de.zendric.app.xpensely_Server.controller; + +import de.zendric.app.xpensely_server.controller.ExpenseListController; +import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.model.ExpenseList; +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; +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 java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(ExpenseListController.class) +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +class ExpenseListControllerTest { + + @Autowired MockMvc mockMvc; + @MockitoBean ExpenseListService expenseListService; + @MockitoBean UserService userService; + @MockitoBean CategoryService categoryService; + @MockitoBean AuthenticatedUserResolver authenticatedUserResolver; + + // --- Validation tests --- + + @Test + void addExpense_blankTitle_returns400() throws Exception { + mockMvc.perform(post("/api/expenselist/1/add") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"\",\"owner\":\"alice\",\"amount\":10.0,\"date\":\"2026-05-04\",\"category\":\"Food\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.title").exists()); + } + + @Test + void addExpense_negativeAmount_returns400() throws Exception { + mockMvc.perform(post("/api/expenselist/1/add") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"Lunch\",\"owner\":\"alice\",\"amount\":-5.0,\"date\":\"2026-05-04\",\"category\":\"Food\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.amount").exists()); + } + + @Test + void addExpense_nullDate_returns400() throws Exception { + mockMvc.perform(post("/api/expenselist/1/add") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"Lunch\",\"owner\":\"alice\",\"amount\":10.0,\"category\":\"Food\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.date").exists()); + } + + @Test + void acceptInvite_blankCode_returns400() throws Exception { + mockMvc.perform(post("/api/expenselist/accept-invite") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"inviteCode\":\"\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.inviteCode").exists()); + } + + @Test + void acceptInvite_wrongCodeLength_returns400() throws Exception { + mockMvc.perform(post("/api/expenselist/accept-invite") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"inviteCode\":\"ABC\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.inviteCode").exists()); + } + + // --- Authorization tests --- + + @Test + void getById_authenticatedUserNotMember_returns403() throws Exception { + AppUser owner = new AppUser(); owner.setId(1L); + AppUser requester = new AppUser(); requester.setId(2L); + ExpenseList list = new ExpenseList(); list.setId(1L); list.setOwner(owner); + + when(expenseListService.findById(1L)).thenReturn(Optional.of(list)); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(requester); + + mockMvc.perform(get("/api/expenselist/byId").param("id", "1")) + .andExpect(status().isForbidden()); + } + + @Test + void getById_authenticatedUserIsOwner_returns200() throws Exception { + AppUser owner = new AppUser(); owner.setId(1L); + ExpenseList list = new ExpenseList(); list.setId(1L); list.setOwner(owner); + + when(expenseListService.findById(1L)).thenReturn(Optional.of(list)); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(owner); + + mockMvc.perform(get("/api/expenselist/byId").param("id", "1")) + .andExpect(status().isOk()); + } + + @Test + void deleteList_nonOwner_returns403() throws Exception { + AppUser owner = new AppUser(); owner.setId(1L); + AppUser nonOwner = new AppUser(); nonOwner.setId(2L); + ExpenseList list = new ExpenseList(); list.setId(5L); list.setOwner(owner); + + when(expenseListService.findById(5L)).thenReturn(Optional.of(list)); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(nonOwner); + + mockMvc.perform(delete("/api/expenselist/5")) + .andExpect(status().isForbidden()); + } + + @Test + void getMine_returnsCurrentUserLists() throws Exception { + AppUser user = new AppUser(); user.setId(3L); + + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(user); + when(expenseListService.findByUserId(3L)).thenReturn(List.of(new ExpenseList())); + + mockMvc.perform(get("/api/expenselist/mine")) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/de/zendric/app/xpensely_Server/security/AuthenticatedUserResolverTest.java b/src/test/java/de/zendric/app/xpensely_Server/security/AuthenticatedUserResolverTest.java new file mode 100644 index 0000000..e854437 --- /dev/null +++ b/src/test/java/de/zendric/app/xpensely_Server/security/AuthenticatedUserResolverTest.java @@ -0,0 +1,78 @@ +package de.zendric.app.xpensely_Server.security; + +import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; +import de.zendric.app.xpensely_server.services.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.web.server.ResponseStatusException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class AuthenticatedUserResolverTest { + + UserService userService; + AuthenticatedUserResolver resolver; + + @BeforeEach + void setUp() { + userService = mock(UserService.class); + resolver = new AuthenticatedUserResolver(userService); + } + + @Test + void resolveCurrentUser_validJwt_returnsAppUser() { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "RS256") + .subject("google-id-123") + .build(); + JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt); + + AppUser user = new AppUser(); + user.setId(1L); + user.setGoogleId("google-id-123"); + when(userService.getUserByGoogleId("google-id-123")).thenReturn(user); + + AppUser result = resolver.resolveCurrentUser(auth); + assertEquals(user, result); + } + + @Test + void resolveCurrentUser_userNotFound_throws403() { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "RS256") + .subject("unknown-id") + .build(); + JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt); + when(userService.getUserByGoogleId("unknown-id")).thenReturn(null); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> resolver.resolveCurrentUser(auth)); + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + } + + @Test + void resolveCurrentUser_userServiceThrows_throws403() { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "RS256") + .subject("gone-id") + .build(); + JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt); + when(userService.getUserByGoogleId("gone-id")).thenThrow(new IllegalArgumentException("not found")); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> resolver.resolveCurrentUser(auth)); + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + } + + @Test + void resolveCurrentUser_nullAuthentication_throws403() { + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> resolver.resolveCurrentUser(null)); + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + } +} diff --git a/src/test/java/de/zendric/app/xpensely_Server/security/RateLimitFilterTest.java b/src/test/java/de/zendric/app/xpensely_Server/security/RateLimitFilterTest.java new file mode 100644 index 0000000..825832b --- /dev/null +++ b/src/test/java/de/zendric/app/xpensely_Server/security/RateLimitFilterTest.java @@ -0,0 +1,89 @@ +package de.zendric.app.xpensely_Server.security; + +import de.zendric.app.xpensely_server.security.RateLimitFilter; +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +class RateLimitFilterTest { + + RateLimitFilter filter; + FilterChain chain; + + @BeforeEach + void setUp() { + filter = new RateLimitFilter(); + chain = mock(FilterChain.class); + } + + @Test + void allowsRequestUnderLimit() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("1.2.3.4"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, chain); + + verify(chain, times(1)).doFilter(request, response); + assertEquals(200, response.getStatus()); + } + + @Test + void blocksRequestOverLimit() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("5.6.7.8"); + + for (int i = 0; i < 60; i++) { + filter.doFilter(request, new MockHttpServletResponse(), chain); + } + + MockHttpServletResponse blockedResponse = new MockHttpServletResponse(); + filter.doFilter(request, blockedResponse, chain); + + assertEquals(429, blockedResponse.getStatus()); + verify(chain, times(60)).doFilter(eq(request), any()); + } + + @Test + void differentIpsBucketedSeparately() throws Exception { + MockHttpServletRequest req1 = new MockHttpServletRequest(); + req1.setRemoteAddr("10.0.0.1"); + MockHttpServletRequest req2 = new MockHttpServletRequest(); + req2.setRemoteAddr("10.0.0.2"); + + for (int i = 0; i < 60; i++) { + filter.doFilter(req1, new MockHttpServletResponse(), chain); + } + + MockHttpServletResponse response2 = new MockHttpServletResponse(); + filter.doFilter(req2, response2, chain); + + assertEquals(200, response2.getStatus()); + } + + @Test + void prefersXForwardedForHeader() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("192.168.1.1"); + request.addHeader("X-Forwarded-For", "203.0.113.5, 10.0.0.1"); + + for (int i = 0; i < 60; i++) { + filter.doFilter(request, new MockHttpServletResponse(), chain); + } + + MockHttpServletResponse blocked = new MockHttpServletResponse(); + filter.doFilter(request, blocked, chain); + assertEquals(429, blocked.getStatus()); + + MockHttpServletRequest directRequest = new MockHttpServletRequest(); + directRequest.setRemoteAddr("192.168.1.1"); + MockHttpServletResponse directResponse = new MockHttpServletResponse(); + filter.doFilter(directRequest, directResponse, chain); + assertEquals(200, directResponse.getStatus()); + } +} diff --git a/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java b/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java new file mode 100644 index 0000000..0f832a5 --- /dev/null +++ b/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java @@ -0,0 +1,57 @@ +package de.zendric.app.xpensely_Server.services; + +import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.model.ExpenseList; +import de.zendric.app.xpensely_server.repo.ExpenseListRepository; +import de.zendric.app.xpensely_server.repo.ExpenseRepository; +import de.zendric.app.xpensely_server.repo.XpenselyCustomCategoryRepository; +import de.zendric.app.xpensely_server.services.ExpenseListService; +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 java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +class ExpenseListServiceTest { + + @Mock ExpenseListRepository repository; + @Mock ExpenseRepository expenseRepository; + @Mock XpenselyCustomCategoryRepository customCategoryRepository; + + @InjectMocks + ExpenseListService service; + + @Test + void findByUserId_usesRepositoryQuery_notFindAll() { + AppUser owner = new AppUser(); owner.setId(1L); + ExpenseList list = new ExpenseList(); list.setId(10L); list.setOwner(owner); + when(repository.findByOwnerIdOrSharedWithId(1L)).thenReturn(List.of(list)); + + List result = service.findByUserId(1L); + + assertThat(result).hasSize(1); + verify(repository).findByOwnerIdOrSharedWithId(1L); + verify(repository, never()).findAll(); + } + + @Test + void findByUsername_usesRepositoryQuery_notFindAll() { + AppUser owner = new AppUser(); owner.setId(1L); owner.setUsername("alice"); + ExpenseList list = new ExpenseList(); list.setId(10L); list.setOwner(owner); + when(repository.findByOwnerUsernameOrSharedWithUsername("alice")).thenReturn(List.of(list)); + + List result = service.findByUsername("alice"); + + assertThat(result).hasSize(1); + verify(repository).findByOwnerUsernameOrSharedWithUsername("alice"); + verify(repository, never()).findAll(); + } +} diff --git a/src/test/java/de/zendric/app/xpensely_Server/services/UserServiceTest.java b/src/test/java/de/zendric/app/xpensely_Server/services/UserServiceTest.java new file mode 100644 index 0000000..9202c92 --- /dev/null +++ b/src/test/java/de/zendric/app/xpensely_Server/services/UserServiceTest.java @@ -0,0 +1,75 @@ +package de.zendric.app.xpensely_Server.services; + +import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.model.Exception.ResourceNotFoundException; +import de.zendric.app.xpensely_server.repo.UserRepository; +import de.zendric.app.xpensely_server.services.UserService; +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 java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock + UserRepository userRepository; + + @InjectMocks + UserService userService; + + @Test + void getUserByName_throwsResourceNotFound_whenUserMissing() { + when(userRepository.findByUsername("ghost")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.getUserByName("ghost")) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining("ghost"); + } + + @Test + void getUserByName_returnsUser_whenFound() { + AppUser user = new AppUser(); + user.setId(1L); + user.setUsername("alice"); + when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user)); + + AppUser result = userService.getUserByName("alice"); + + assertThat(result.getUsername()).isEqualTo("alice"); + } + + @Test + void getUser_throwsResourceNotFound_whenIdMissing() { + when(userRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.getUser(99L)) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining("99"); + } + + @Test + void deleteUserById_throwsResourceNotFound_whenIdMissing() { + when(userRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.deleteUserById(99L)) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining("99"); + } + + @Test + void getUserByGoogleId_throwsResourceNotFound_whenMissing() { + when(userRepository.findByGoogleId("gid-404")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.getUserByGoogleId("gid-404")) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining("gid-404"); + } +}