diff --git a/pom.xml b/pom.xml index 45528c4..9d421c4 100644 --- a/pom.xml +++ b/pom.xml @@ -6,11 +6,11 @@ org.springframework.boot spring-boot-starter-parent 4.0.6 - + de.zendric.app XpenselyServer - 1.0.0 + 1.1.0 XpenselyServer XpenselyServer used to handle the Xpensely App @@ -52,10 +52,6 @@ org.springframework.boot spring-boot-starter-oauth2-resource-server - - org.springframework.boot - spring-boot-starter-oauth2-client - org.springframework.boot spring-boot-starter-web 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 597824b..a3a483b 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 @@ -9,6 +9,7 @@ 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.UsernameUpdateRequest; import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; import de.zendric.app.xpensely_server.services.UserService; @@ -31,11 +32,6 @@ public class AppUserController { return ResponseEntity.ok(userService.getUser(id)); } - @GetMapping("/byName") - public AppUser getUserByName(@RequestParam String username) { - return userService.getUserByName(username); - } - @GetMapping("/byGoogleId") public ResponseEntity getUserByGoogleId(@RequestParam String id, Authentication authentication) { AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication); @@ -51,6 +47,14 @@ public class AppUserController { return new ResponseEntity<>(nUser, HttpStatus.CREATED); } + @PutMapping("/username") + public ResponseEntity updateUsername(@RequestBody @Valid UsernameUpdateRequest request, + Authentication authentication) { + AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication); + AppUser updated = userService.updateUsername(self.getId(), request.getUsername()); + return ResponseEntity.ok(updated); + } + @DeleteMapping public ResponseEntity deleteUser(@RequestParam Long id, Authentication authentication) { AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication); 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 a2227e3..ae4bb58 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 @@ -17,7 +17,6 @@ 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") @@ -26,14 +25,12 @@ public class ExpenseListController { private static final Logger log = LoggerFactory.getLogger(ExpenseListController.class); private final ExpenseListService expenseListService; - private final UserService userService; private final CategoryService categoryService; private final AuthenticatedUserResolver authenticatedUserResolver; - public ExpenseListController(ExpenseListService expenseListService, UserService userService, + public ExpenseListController(ExpenseListService expenseListService, CategoryService categoryService, AuthenticatedUserResolver authenticatedUserResolver) { this.expenseListService = expenseListService; - this.userService = userService; this.categoryService = categoryService; this.authenticatedUserResolver = authenticatedUserResolver; } @@ -93,7 +90,7 @@ public class ExpenseListController { if (listOpt.isEmpty()) return new ResponseEntity<>(HttpStatus.NOT_FOUND); assertMember(user, listOpt.get()); - AppUser expenseOwner = userService.getUserByName(expenseInput.getOwner()); + AppUser expenseOwner = resolveListMember(listOpt.get(), expenseInput.getOwner()); Expense expense = expenseInput.convertToExpense(expenseOwner.getId()); Expense addedExpense = expenseListService.addExpenseToList(expenseListId, expense); return new ResponseEntity<>(addedExpense, HttpStatus.CREATED); @@ -109,7 +106,7 @@ public class ExpenseListController { if (expenseListOpt.isEmpty()) return new ResponseEntity<>(HttpStatus.NOT_FOUND); assertMember(user, expenseListOpt.get()); - AppUser expenseOwner = userService.getUserByName(expenseChangeRequest.getOwnerName()); + AppUser expenseOwner = resolveListMember(expenseListOpt.get(), expenseChangeRequest.getOwnerName()); Expense expense = expenseChangeRequest.convertToExpense(expenseOwner.getId(), expenseListOpt.get()); Expense updatedExpense = expenseListService.updateExpense(expenseListId, expense); return new ResponseEntity<>(updatedExpense, HttpStatus.OK); @@ -156,6 +153,11 @@ public class ExpenseListController { if (list.getOwner().getId().equals(authenticatedUser.getId())) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("You cannot join your own list"); } + if (list.getOwner().getUsername().equals(authenticatedUser.getUsername())) { + return ResponseEntity.status(HttpStatus.CONFLICT).body( + "You and the list owner both use the username \"" + authenticatedUser.getUsername() + + "\". Please change your username before joining this list."); + } list.setSharedWith(authenticatedUser); expenseListService.save(list); return ResponseEntity.ok("User added to the list"); @@ -173,4 +175,20 @@ public class ExpenseListController { if (!isOwner && !isShared) throw new ResponseStatusException(HttpStatus.FORBIDDEN); } + + /** + * Resolves an expense owner by username among the list's members. Usernames are + * not globally unique, so the owner must be one of this list's (at most two) + * members — never a global lookup. + */ + private AppUser resolveListMember(ExpenseList list, String username) { + if (list.getOwner() != null && list.getOwner().getUsername().equals(username)) { + return list.getOwner(); + } + if (list.getSharedWith() != null && list.getSharedWith().getUsername().equals(username)) { + return list.getSharedWith(); + } + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Expense owner must be a member of this list"); + } } 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 index 5a0c270..2a923f8 100644 --- a/src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java +++ b/src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java @@ -1,5 +1,6 @@ package de.zendric.app.xpensely_server.controller; +import de.zendric.app.xpensely_server.model.Exception.GoogleAccountAlreadyRegisteredException; import de.zendric.app.xpensely_server.model.Exception.ResourceNotFoundException; import de.zendric.app.xpensely_server.model.Exception.UsernameAlreadyExistsException; import org.slf4j.Logger; @@ -47,6 +48,13 @@ public class GlobalExceptionHandler { .body(Map.of("error", ex.getMessage())); } + @ExceptionHandler(GoogleAccountAlreadyRegisteredException.class) + public ResponseEntity> handleGoogleAccountConflict( + GoogleAccountAlreadyRegisteredException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", ex.getMessage())); + } + @ExceptionHandler(ResponseStatusException.class) public ResponseEntity> handleResponseStatus(ResponseStatusException ex) { return ResponseEntity.status(ex.getStatusCode()) diff --git a/src/main/java/de/zendric/app/xpensely_server/model/AppUser.java b/src/main/java/de/zendric/app/xpensely_server/model/AppUser.java index 90a0cd9..4a98266 100644 --- a/src/main/java/de/zendric/app/xpensely_server/model/AppUser.java +++ b/src/main/java/de/zendric/app/xpensely_server/model/AppUser.java @@ -26,9 +26,10 @@ public class AppUser { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(name = "username", nullable = false, unique = true) + @Column(name = "username", nullable = false) private String username; + @Column(name = "google_id", unique = true) @JsonIgnore private String googleId; diff --git a/src/main/java/de/zendric/app/xpensely_server/model/Exception/GoogleAccountAlreadyRegisteredException.java b/src/main/java/de/zendric/app/xpensely_server/model/Exception/GoogleAccountAlreadyRegisteredException.java new file mode 100644 index 0000000..5e34eb7 --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/model/Exception/GoogleAccountAlreadyRegisteredException.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.CONFLICT) +public class GoogleAccountAlreadyRegisteredException extends RuntimeException { + public GoogleAccountAlreadyRegisteredException(String message) { + super(message); + } +} diff --git a/src/main/java/de/zendric/app/xpensely_server/model/UsernameUpdateRequest.java b/src/main/java/de/zendric/app/xpensely_server/model/UsernameUpdateRequest.java new file mode 100644 index 0000000..7be09b8 --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/model/UsernameUpdateRequest.java @@ -0,0 +1,15 @@ +package de.zendric.app.xpensely_server.model; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class UsernameUpdateRequest { + + @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; +} diff --git a/src/main/java/de/zendric/app/xpensely_server/repo/UserRepository.java b/src/main/java/de/zendric/app/xpensely_server/repo/UserRepository.java index a35ba4f..00945c2 100644 --- a/src/main/java/de/zendric/app/xpensely_server/repo/UserRepository.java +++ b/src/main/java/de/zendric/app/xpensely_server/repo/UserRepository.java @@ -14,4 +14,6 @@ public interface UserRepository extends JpaRepository { Optional findByGoogleId(String id); Boolean existsByUsername(String username); + + Boolean existsByGoogleId(String googleId); } 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 a86d665..cf9bee8 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 @@ -5,8 +5,8 @@ import java.util.List; import org.springframework.stereotype.Service; import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.model.Exception.GoogleAccountAlreadyRegisteredException; 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; @Service @@ -22,9 +22,18 @@ public class UserService { } public AppUser createUser(AppUser user) { - if (Boolean.TRUE.equals(userRepository.existsByUsername(user.getUsername()))) { - throw new UsernameAlreadyExistsException("Username already exists"); + if (Boolean.TRUE.equals(userRepository.existsByGoogleId(user.getGoogleId()))) { + throw new GoogleAccountAlreadyRegisteredException( + "An account already exists for this Google account"); } + // Usernames are not unique — identity is the Google account (googleId). + return userRepository.save(user); + } + + public AppUser updateUsername(Long userId, String newUsername) { + AppUser user = userRepository.findById(userId) + .orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + userId)); + user.setUsername(newUsername); return userRepository.save(user); } 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 index b99093c..f23efe9 100644 --- a/src/test/java/de/zendric/app/xpensely_Server/controller/AppUserControllerTest.java +++ b/src/test/java/de/zendric/app/xpensely_Server/controller/AppUserControllerTest.java @@ -6,8 +6,6 @@ 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.security.oauth2.client.autoconfigure.OAuth2ClientAutoConfiguration; -import org.springframework.boot.security.oauth2.client.autoconfigure.servlet.OAuth2ClientWebSecurityAutoConfiguration; import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; @@ -22,8 +20,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(value = AppUserController.class, excludeAutoConfiguration = { - OAuth2ClientAutoConfiguration.class, - OAuth2ClientWebSecurityAutoConfiguration.class, OAuth2ResourceServerAutoConfiguration.class }) @AutoConfigureMockMvc(addFilters = false) @@ -99,4 +95,38 @@ class AppUserControllerTest { mockMvc.perform(delete("/api/users").param("id", "99")) .andExpect(status().isForbidden()); } + + // --- Rename username --- + + @Test + void updateUsername_valid_returns200WithUpdatedUser() throws Exception { + AppUser self = new AppUser(); self.setId(1L); self.setUsername("old"); + AppUser updated = new AppUser(); updated.setId(1L); updated.setUsername("newname"); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(self); + when(userService.updateUsername(1L, "newname")).thenReturn(updated); + + mockMvc.perform(put("/api/users/username") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"newname\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username").value("newname")); + } + + @Test + void updateUsername_invalidPattern_returns400() throws Exception { + mockMvc.perform(put("/api/users/username") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"bad name!\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.username").exists()); + } + + @Test + void updateUsername_tooShort_returns400() throws Exception { + mockMvc.perform(put("/api/users/username") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"ab\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.username").exists()); + } } 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 index 5c50950..50913ea 100644 --- a/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java +++ b/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java @@ -2,15 +2,13 @@ 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.Expense; 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.security.oauth2.client.autoconfigure.OAuth2ClientAutoConfiguration; -import org.springframework.boot.security.oauth2.client.autoconfigure.servlet.OAuth2ClientWebSecurityAutoConfiguration; import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; @@ -19,17 +17,17 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; 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(value = ExpenseListController.class, excludeAutoConfiguration = { - OAuth2ClientAutoConfiguration.class, - OAuth2ClientWebSecurityAutoConfiguration.class, OAuth2ResourceServerAutoConfiguration.class }) @AutoConfigureMockMvc(addFilters = false) @@ -38,7 +36,6 @@ class ExpenseListControllerTest { @Autowired MockMvc mockMvc; @MockitoBean ExpenseListService expenseListService; - @MockitoBean UserService userService; @MockitoBean CategoryService categoryService; @MockitoBean AuthenticatedUserResolver authenticatedUserResolver; @@ -178,4 +175,55 @@ class ExpenseListControllerTest { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.name").exists()); } + + // --- Owner resolution within list members --- + + @Test + void addExpense_ownerNotAListMember_returns400() throws Exception { + AppUser member = new AppUser(); member.setId(1L); member.setUsername("alice"); + ExpenseList list = new ExpenseList(); list.setId(1L); list.setOwner(member); + + when(expenseListService.findById(1L)).thenReturn(Optional.of(list)); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(member); + + mockMvc.perform(post("/api/expenselist/1/add") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"Lunch\",\"owner\":\"ghost\",\"amount\":10.0,\"date\":\"2026-05-04\",\"category\":\"Food\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void addExpense_ownerIsAListMember_returns201() throws Exception { + AppUser member = new AppUser(); member.setId(1L); member.setUsername("alice"); + ExpenseList list = new ExpenseList(); list.setId(1L); list.setOwner(member); + + when(expenseListService.findById(1L)).thenReturn(Optional.of(list)); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(member); + when(expenseListService.addExpenseToList(eq(1L), any())).thenReturn(new Expense()); + + mockMvc.perform(post("/api/expenselist/1/add") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"Lunch\",\"owner\":\"alice\",\"amount\":10.0,\"date\":\"2026-05-04\",\"category\":\"Food\"}")) + .andExpect(status().isCreated()); + } + + // --- Duplicate-username invite guard --- + + @Test + void acceptInvite_sameUsernameAsOwner_returns409() throws Exception { + AppUser owner = new AppUser(); owner.setId(1L); owner.setUsername("cedric"); + AppUser joiner = new AppUser(); joiner.setId(2L); joiner.setUsername("cedric"); + ExpenseList list = new ExpenseList(); + list.setId(1L); + list.setOwner(owner); + list.setInviteCodeExpiration(LocalDateTime.now().plusHours(1)); + + when(expenseListService.findByInviteCode("ABC123")).thenReturn(list); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(joiner); + + mockMvc.perform(post("/api/expenselist/accept-invite") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"inviteCode\":\"ABC123\"}")) + .andExpect(status().isConflict()); + } }