feat: migrate from username to email-only authentication #272

Merged
marcel merged 6 commits from feat/issue-270-email-only-auth into main 2026-04-18 23:36:56 +02:00
7 changed files with 67 additions and 4 deletions
Showing only changes of commit 8447f06f36 - Show all commits

View File

@@ -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<AppUser> createUser(@RequestBody CreateUserRequest request) {
public ResponseEntity<AppUser> createUser(@Valid @RequestBody CreateUserRequest request) {
return ResponseEntity.ok(userService.createUserOrUpdate(request));
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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()

View File

@@ -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();

View File

@@ -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());
}
}

View File

@@ -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) {