From e1ddd6670400e8ede32aa3f7015d5a1758e8e63d Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 18 Apr 2026 21:52:11 +0200 Subject: [PATCH] fix(auth): add @Email validation and @Valid to enforce email format on user creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add @Email annotation to CreateUserRequest.email and AppUser.email - Add @Valid to UserController.createUser to activate bean validation - Add MigrationIntegrationTest cases for V44 NOT NULL and UNIQUE constraints - Fix stale test comments (findByUsername → findByEmail) Co-Authored-By: Claude Sonnet 4.6 --- .../controller/UserController.java | 3 +- .../familienarchiv/dto/CreateUserRequest.java | 2 ++ .../raddatz/familienarchiv/model/AppUser.java | 2 ++ .../controller/AnnotationControllerTest.java | 4 +-- .../controller/CommentControllerTest.java | 2 +- .../controller/UserControllerTest.java | 30 +++++++++++++++++++ .../repository/MigrationIntegrationTest.java | 28 +++++++++++++++++ 7 files changed, 67 insertions(+), 4 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java index 2c9bd968..1813c37d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import jakarta.validation.Valid; import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest; import org.raddatz.familienarchiv.dto.ChangePasswordDTO; import org.raddatz.familienarchiv.dto.CreateUserRequest; @@ -77,7 +78,7 @@ public class UserController { @PostMapping("/users") @RequirePermission(Permission.ADMIN_USER) - public ResponseEntity createUser(@RequestBody CreateUserRequest request) { + public ResponseEntity createUser(@Valid @RequestBody CreateUserRequest request) { return ResponseEntity.ok(userService.createUserOrUpdate(request)); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateUserRequest.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateUserRequest.java index 2b474c5d..a48ff565 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateUserRequest.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateUserRequest.java @@ -1,5 +1,6 @@ package org.raddatz.familienarchiv.dto; +import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import lombok.Data; @@ -11,6 +12,7 @@ import java.util.UUID; @Data public class CreateUserRequest { @NotBlank + @Email @Pattern(regexp = "^[^:]+$", message = "Email must not contain a colon") private String email; private String initialPassword; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/AppUser.java b/backend/src/main/java/org/raddatz/familienarchiv/model/AppUser.java index 89c340a3..3ac33625 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/AppUser.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/AppUser.java @@ -1,6 +1,7 @@ package org.raddatz.familienarchiv.model; import jakarta.persistence.*; +import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import lombok.*; @@ -33,6 +34,7 @@ public class AppUser { @Column(unique = true, nullable = false) @NotBlank + @Email @Pattern(regexp = "^[^:]+$", message = "Email must not contain a colon") @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private String email; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java index e7246f77..7a4546b6 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java @@ -278,7 +278,7 @@ class AnnotationControllerTest { @Test @WithMockUser(authorities = "ANNOTATE_ALL") void createAnnotation_resolvesNullUserId_whenUserServiceThrows() throws Exception { - // findByUsername throws → catch block → resolveUserId returns null + // findByEmail throws → catch block → resolveUserId returns null UUID docId = UUID.randomUUID(); when(userService.findByEmail(any())).thenThrow(new RuntimeException("DB error")); DocumentAnnotation saved = DocumentAnnotation.builder() @@ -296,7 +296,7 @@ class AnnotationControllerTest { @Test @WithMockUser(authorities = "ANNOTATE_ALL") void createAnnotation_resolvesNullUserId_whenUserServiceReturnsNull() throws Exception { - // findByUsername returns null → user != null = false → resolveUserId returns null + // findByEmail returns null → user != null = false → resolveUserId returns null UUID docId = UUID.randomUUID(); when(userService.findByEmail(any())).thenReturn(null); DocumentAnnotation saved = DocumentAnnotation.builder() diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java index 94685447..63ad617b 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java @@ -269,7 +269,7 @@ class CommentControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void postDocumentComment_stillSucceeds_whenUserServiceThrows() throws Exception { - // findByUsername throws → catch block in resolveUser → author null, saves anyway + // findByEmail throws → catch block in resolveUser → author null, saves anyway when(userService.findByEmail(any())).thenThrow(new RuntimeException("DB error")); DocumentComment saved = DocumentComment.builder() .id(UUID.randomUUID()).documentId(DOC_ID).content("Test comment").build(); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/UserControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/UserControllerTest.java index 99b1d1f2..2b330a83 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/UserControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/UserControllerTest.java @@ -19,6 +19,7 @@ import java.util.UUID; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -74,4 +75,33 @@ class UserControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.email").value("target@example.com")); } + + // ─── POST /api/users ────────────────────────────────────────────────────── + + @Test + @WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"}) + void createUser_returns400_whenEmailIsNotValidEmailFormat() throws Exception { + mockMvc.perform(post("/api/users") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content("{\"email\":\"notanemail\",\"initialPassword\":\"secret123\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"}) + void createUser_returns400_whenEmailContainsColon() throws Exception { + mockMvc.perform(post("/api/users") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content("{\"email\":\"user:name@example.com\",\"initialPassword\":\"secret123\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"}) + void createUser_returns400_whenEmailIsBlank() throws Exception { + mockMvc.perform(post("/api/users") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content("{\"email\":\"\",\"initialPassword\":\"secret123\"}")) + .andExpect(status().isBadRequest()); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java index da1ab7f4..7709b486 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java @@ -274,6 +274,34 @@ class MigrationIntegrationTest { assertThat(rows2).isEqualTo(1); } + // ─── V44: email NOT NULL constraint ────────────────────────────────────── + + @Test + void v44_emailNotNullConstraint_rejectsInsertWithNullEmail() { + assertThatThrownBy(() -> + jdbc.update(""" + INSERT INTO users (id, email, password, enabled, notify_on_reply, notify_on_mention) + VALUES (gen_random_uuid(), NULL, 'hash', true, false, false) + """) + ).isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void v44_emailUniqueConstraint_rejectsDuplicateEmail() { + String email = "unique-test-" + UUID.randomUUID() + "@example.com"; + jdbc.update(""" + INSERT INTO users (id, email, password, enabled, notify_on_reply, notify_on_mention) + VALUES (gen_random_uuid(), ?, 'hash', true, false, false) + """, email); + + assertThatThrownBy(() -> + jdbc.update(""" + INSERT INTO users (id, email, password, enabled, notify_on_reply, notify_on_mention) + VALUES (gen_random_uuid(), ?, 'hash', true, false, false) + """, email) + ).isInstanceOf(DataIntegrityViolationException.class); + } + // ─── helpers ───────────────────────────────────────────────────────────── private UUID createPerson(String firstName, String lastName) {