From e7c7f801c9aedba08d2da8dd703a1dbd0dd7afd2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 26 Apr 2026 14:48:35 +0200 Subject: [PATCH 01/14] feat(audit): emit USER_CREATED when admin creates a new user Adds USER_CREATED, USER_DELETED, GROUP_MEMBERSHIP_CHANGED to AuditKind. Injects AuditService into UserService; changes createUserOrUpdate to accept actorId and emits logAfterCommit(USER_CREATED) only on the new-user branch. Updates UserController to resolve and pass actorId. Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/audit/AuditKind.java | 11 +++- .../controller/UserController.java | 6 +- .../familienarchiv/service/UserService.java | 16 ++++- .../service/UserServiceTest.java | 60 +++++++++++++++++-- 4 files changed, 83 insertions(+), 10 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java index 5a89f081..ef2939a0 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java @@ -26,7 +26,16 @@ public enum AuditKind { COMMENT_ADDED, /** Payload: {@code {"commentId": "uuid", "mentionedUserId": "uuid"}} */ - MENTION_CREATED; + MENTION_CREATED, + + /** Payload: {@code {"userId": "uuid", "email": "addr"}} */ + USER_CREATED, + + /** Payload: {@code {"userId": "uuid", "email": "addr"}} */ + USER_DELETED, + + /** Payload: {@code {"userId": "uuid", "email": "addr", "addedGroups": ["Admin"], "removedGroups": []}} */ + GROUP_MEMBERSHIP_CHANGED; public static final Set ROLLUP_ELIGIBLE = Set.of( TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED, 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 1813c37d..bcd8b9bd 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java @@ -78,8 +78,10 @@ public class UserController { @PostMapping("/users") @RequirePermission(Permission.ADMIN_USER) - public ResponseEntity createUser(@Valid @RequestBody CreateUserRequest request) { - return ResponseEntity.ok(userService.createUserOrUpdate(request)); + public ResponseEntity createUser(Authentication authentication, + @Valid @RequestBody CreateUserRequest request) { + AppUser actor = userService.findByEmail(authentication.getName()); + return ResponseEntity.ok(userService.createUserOrUpdate(actor.getId(), request)); } @PutMapping("/users/{id}") diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java index 5cdb47a5..fe77cb87 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java @@ -3,6 +3,8 @@ package org.raddatz.familienarchiv.service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.audit.AuditKind; +import org.raddatz.familienarchiv.audit.AuditService; import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest; import org.raddatz.familienarchiv.dto.ChangePasswordDTO; import org.raddatz.familienarchiv.dto.CreateUserRequest; @@ -21,6 +23,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.Collection; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -33,9 +36,10 @@ public class UserService { private final AppUserRepository userRepository; private final UserGroupRepository groupRepository; private final PasswordEncoder passwordEncoder; + private final AuditService auditService; @Transactional - public AppUser createUserOrUpdate(CreateUserRequest request) { + public AppUser createUserOrUpdate(UUID actorId, CreateUserRequest request) { log.info("Creating or updating user: {}", request.getEmail()); Set groups = new HashSet<>(); @@ -45,10 +49,12 @@ public class UserService { Optional existingUser = userRepository.findByEmail(request.getEmail()); AppUser user; + boolean isNew; if (existingUser.isPresent()) { log.info("User exists, updating: {}", request.getEmail()); user = existingUser.get().updateFromRequest(request, passwordEncoder, groups); + isNew = false; } else { log.info("Creating new user: {}", request.getEmail()); user = AppUser.builder() @@ -61,9 +67,15 @@ public class UserService { .contact(request.getContact()) .enabled(true) .build(); + isNew = true; } - return userRepository.save(user); + AppUser saved = userRepository.save(user); + if (isNew) { + auditService.logAfterCommit(AuditKind.USER_CREATED, actorId, null, + Map.of("userId", saved.getId().toString(), "email", saved.getEmail())); + } + return saved; } @Transactional diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java index 15c684e4..5062d2c1 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java @@ -2,9 +2,12 @@ package org.raddatz.familienarchiv.service; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.audit.AuditKind; +import org.raddatz.familienarchiv.audit.AuditService; import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest; import org.raddatz.familienarchiv.dto.ChangePasswordDTO; import org.raddatz.familienarchiv.dto.CreateUserRequest; @@ -34,6 +37,7 @@ class UserServiceTest { @Mock AppUserRepository userRepository; @Mock UserGroupRepository groupRepository; @Mock PasswordEncoder passwordEncoder; + @Mock AuditService auditService; @InjectMocks UserService userService; // ─── findByEmail ────────────────────────────────────────────────────────── @@ -90,7 +94,7 @@ class UserServiceTest { AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build(); when(userRepository.save(any())).thenReturn(saved); - AppUser result = userService.createUserOrUpdate(req); + AppUser result = userService.createUserOrUpdate(UUID.randomUUID(), req); assertThat(result).isEqualTo(saved); verify(userRepository).save(any()); @@ -108,7 +112,7 @@ class UserServiceTest { when(passwordEncoder.encode(any())).thenReturn("encoded"); when(userRepository.save(any())).thenReturn(existing); - userService.createUserOrUpdate(req); + userService.createUserOrUpdate(UUID.randomUUID(), req); verify(userRepository, times(1)).save(existing); } @@ -313,7 +317,7 @@ class UserServiceTest { AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build(); when(userRepository.save(any())).thenReturn(saved); - AppUser result = userService.createUserOrUpdate(req); + AppUser result = userService.createUserOrUpdate(UUID.randomUUID(), req); assertThat(result).isEqualTo(saved); verify(groupRepository).findAllById(List.of(group.getId())); @@ -497,7 +501,7 @@ class UserServiceTest { AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build(); when(userRepository.save(any())).thenReturn(saved); - userService.createUserOrUpdate(req); + userService.createUserOrUpdate(UUID.randomUUID(), req); verify(groupRepository, never()).findAllById(any()); } @@ -640,7 +644,7 @@ class UserServiceTest { AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("ng@example.com").build(); when(userRepository.save(any())).thenReturn(saved); - userService.createUserOrUpdate(req); + userService.createUserOrUpdate(UUID.randomUUID(), req); verify(groupRepository, never()).findAllById(any()); } @@ -699,6 +703,52 @@ class UserServiceTest { assertThat(result).containsExactly(g); } + // ─── audit: USER_CREATED ────────────────────────────────────────────────── + + @Test + void createUserOrUpdate_logsUserCreated_whenUserIsNew() { + UUID actorId = UUID.randomUUID(); + CreateUserRequest req = new CreateUserRequest(); + req.setEmail("new@example.com"); + req.setInitialPassword("secret"); + req.setGroupIds(List.of()); + + when(userRepository.findByEmail("new@example.com")).thenReturn(Optional.empty()); + when(passwordEncoder.encode("secret")).thenReturn("encoded"); + AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build(); + when(userRepository.save(any())).thenReturn(saved); + + userService.createUserOrUpdate(actorId, req); + + @SuppressWarnings("unchecked") + ArgumentCaptor> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class); + verify(auditService).logAfterCommit( + org.mockito.ArgumentMatchers.eq(AuditKind.USER_CREATED), + org.mockito.ArgumentMatchers.eq(actorId), + org.mockito.ArgumentMatchers.isNull(), + payloadCaptor.capture()); + assertThat(payloadCaptor.getValue()).containsKey("userId"); + assertThat(payloadCaptor.getValue()).containsEntry("email", "new@example.com"); + } + + @Test + void createUserOrUpdate_doesNotLogUserCreated_whenUserAlreadyExists() { + UUID actorId = UUID.randomUUID(); + CreateUserRequest req = new CreateUserRequest(); + req.setEmail("existing@example.com"); + req.setInitialPassword("pass"); + req.setGroupIds(List.of()); + + AppUser existing = AppUser.builder().id(UUID.randomUUID()).email("existing@example.com").build(); + when(userRepository.findByEmail("existing@example.com")).thenReturn(Optional.of(existing)); + when(passwordEncoder.encode(any())).thenReturn("encoded"); + when(userRepository.save(any())).thenReturn(existing); + + userService.createUserOrUpdate(actorId, req); + + verify(auditService, never()).logAfterCommit(any(), any(), any(), any()); + } + // ─── createGroup ────────────────────────────────────────────────────────── @Test -- 2.49.1 From a736b7399a0fc7465b74d7d46d544277d8273ba4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 26 Apr 2026 14:51:15 +0200 Subject: [PATCH 02/14] feat(audit): emit USER_DELETED when admin removes a user Adds actorId param to deleteUser(), captures email before deletion, emits logAfterCommit(USER_DELETED) with userId+email in payload. Updates UserController to resolve and pass actorId. Co-Authored-By: Claude Sonnet 4.6 --- .../controller/UserController.java | 6 +++-- .../familienarchiv/service/UserService.java | 5 +++- .../service/UserServiceTest.java | 26 +++++++++++++++++-- 3 files changed, 32 insertions(+), 5 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 bcd8b9bd..19a78ea7 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java @@ -95,8 +95,10 @@ public class UserController { @DeleteMapping("/users/{id}") @RequirePermission(Permission.ADMIN_USER) - public ResponseEntity deleteUser(@PathVariable UUID id) { - userService.deleteUser(id); + public ResponseEntity deleteUser(Authentication authentication, + @PathVariable UUID id) { + AppUser actor = userService.findByEmail(authentication.getName()); + userService.deleteUser(actor.getId(), id); return ResponseEntity.ok().build(); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java index fe77cb87..3d99ce75 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java @@ -106,10 +106,13 @@ public class UserService { } @Transactional - public void deleteUser(UUID userId) { + public void deleteUser(UUID actorId, UUID userId) { AppUser user = userRepository.findById(userId) .orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + userId)); + String email = user.getEmail(); userRepository.delete(user); + auditService.logAfterCommit(AuditKind.USER_DELETED, actorId, null, + Map.of("userId", userId.toString(), "email", email)); } public AppUser getById(UUID id) { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java index 5062d2c1..b522da90 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java @@ -65,7 +65,7 @@ class UserServiceTest { UUID id = UUID.randomUUID(); when(userRepository.findById(id)).thenReturn(Optional.empty()); - assertThatThrownBy(() -> userService.deleteUser(id)) + assertThatThrownBy(() -> userService.deleteUser(UUID.randomUUID(), id)) .isInstanceOf(DomainException.class); } @@ -75,7 +75,7 @@ class UserServiceTest { AppUser user = AppUser.builder().id(id).email("gast@example.com").build(); when(userRepository.findById(id)).thenReturn(Optional.of(user)); - userService.deleteUser(id); + userService.deleteUser(UUID.randomUUID(), id); verify(userRepository).delete(user); } @@ -703,6 +703,28 @@ class UserServiceTest { assertThat(result).containsExactly(g); } + // ─── audit: USER_DELETED ────────────────────────────────────────────────── + + @Test + void deleteUser_logsUserDeleted_withEmailInPayload() { + UUID actorId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + AppUser user = AppUser.builder().id(userId).email("gone@example.com").build(); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + userService.deleteUser(actorId, userId); + + @SuppressWarnings("unchecked") + ArgumentCaptor> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class); + verify(auditService).logAfterCommit( + org.mockito.ArgumentMatchers.eq(AuditKind.USER_DELETED), + org.mockito.ArgumentMatchers.eq(actorId), + org.mockito.ArgumentMatchers.isNull(), + payloadCaptor.capture()); + assertThat(payloadCaptor.getValue()).containsEntry("email", "gone@example.com"); + assertThat(payloadCaptor.getValue()).containsKey("userId"); + } + // ─── audit: USER_CREATED ────────────────────────────────────────────────── @Test -- 2.49.1 From eb8f9d4dc40ffb6048b5ad7a41d524fa5e705b5d Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 26 Apr 2026 14:54:42 +0200 Subject: [PATCH 03/14] feat(audit): emit GROUP_MEMBERSHIP_CHANGED when admin updates user groups Adds actorId param to adminUpdateUser(), captures beforeGroups before mutation, computes added/removed group names, emits logAfterCommit only when the group set actually changes. Payload contains group names, not permission strings. Co-Authored-By: Claude Sonnet 4.6 --- .../controller/UserController.java | 6 +- .../familienarchiv/service/UserService.java | 22 ++++- .../service/UserServiceTest.java | 92 ++++++++++++++++--- 3 files changed, 102 insertions(+), 18 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 19a78ea7..b3753546 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java @@ -86,9 +86,11 @@ public class UserController { @PutMapping("/users/{id}") @RequirePermission(Permission.ADMIN_USER) - public ResponseEntity adminUpdateUser(@PathVariable UUID id, + public ResponseEntity adminUpdateUser(Authentication authentication, + @PathVariable UUID id, @RequestBody AdminUpdateUserRequest dto) { - AppUser updated = userService.adminUpdateUser(id, dto); + AppUser actor = userService.findByEmail(authentication.getName()); + AppUser updated = userService.adminUpdateUser(actor.getId(), id, dto); updated.setPassword(null); return ResponseEntity.ok(updated); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java index 3d99ce75..251acae4 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java @@ -28,6 +28,8 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; +import static java.util.stream.Collectors.toSet; + @Service @RequiredArgsConstructor @Slf4j @@ -156,7 +158,7 @@ public class UserService { } @Transactional - public AppUser adminUpdateUser(UUID id, AdminUpdateUserRequest dto) { + public AppUser adminUpdateUser(UUID actorId, UUID id, AdminUpdateUserRequest dto) { AppUser user = getById(id); if (dto.getEmail() != null && !dto.getEmail().isBlank()) { @@ -181,8 +183,22 @@ public class UserService { } if (dto.getGroupIds() != null) { - Set groups = new HashSet<>(groupRepository.findAllById(dto.getGroupIds())); - user.setGroups(groups); + Set beforeIds = user.getGroups().stream().map(UserGroup::getId).collect(toSet()); + Set beforeGroups = new HashSet<>(user.getGroups()); + Set newGroups = new HashSet<>(groupRepository.findAllById(dto.getGroupIds())); + user.setGroups(newGroups); + Set afterIds = newGroups.stream().map(UserGroup::getId).collect(toSet()); + if (!beforeIds.equals(afterIds)) { + List added = newGroups.stream() + .filter(g -> !beforeIds.contains(g.getId())) + .map(UserGroup::getName).toList(); + List removed = beforeGroups.stream() + .filter(g -> !afterIds.contains(g.getId())) + .map(UserGroup::getName).toList(); + auditService.logAfterCommit(AuditKind.GROUP_MEMBERSHIP_CHANGED, actorId, null, + Map.of("userId", id.toString(), "email", user.getEmail(), + "addedGroups", added, "removedGroups", removed)); + } } return userRepository.save(user); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java index b522da90..2ff5ce27 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java @@ -233,7 +233,7 @@ class UserServiceTest { AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); dto.setFirstName("Ada"); dto.setLastName("Lovelace"); - AppUser result = userService.adminUpdateUser(id, dto); + AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto); assertThat(result.getFirstName()).isEqualTo("Ada"); assertThat(result.getLastName()).isEqualTo("Lovelace"); @@ -250,7 +250,7 @@ class UserServiceTest { AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); dto.setFirstName("Ada"); - AppUser result = userService.adminUpdateUser(id, dto); + AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto); assertThat(result.getGroups()).containsExactly(adminGroup); } @@ -268,7 +268,7 @@ class UserServiceTest { AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); dto.setGroupIds(List.of(newGroup.getId())); - AppUser result = userService.adminUpdateUser(id, dto); + AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto); assertThat(result.getGroups()).containsExactly(newGroup); } @@ -285,7 +285,7 @@ class UserServiceTest { AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); dto.setGroupIds(List.of()); - AppUser result = userService.adminUpdateUser(id, dto); + AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto); assertThat(result.getGroups()).isEmpty(); } @@ -382,7 +382,7 @@ class UserServiceTest { AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); dto.setNewPassword("newSecret"); - AppUser result = userService.adminUpdateUser(id, dto); + AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto); assertThat(result.getPassword()).isEqualTo("newHashed"); } @@ -397,7 +397,7 @@ class UserServiceTest { AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); dto.setNewPassword(" "); - AppUser result = userService.adminUpdateUser(id, dto); + AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto); assertThat(result.getPassword()).isEqualTo("original"); verify(passwordEncoder, never()).encode(any()); @@ -412,7 +412,7 @@ class UserServiceTest { AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); dto.setEmail(" "); - assertThatThrownBy(() -> userService.adminUpdateUser(id, dto)) + assertThatThrownBy(() -> userService.adminUpdateUser(UUID.randomUUID(), id, dto)) .isInstanceOf(DomainException.class) .hasMessageContaining("blank"); } @@ -429,7 +429,7 @@ class UserServiceTest { AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); dto.setEmail("taken@example.com"); - assertThatThrownBy(() -> userService.adminUpdateUser(id, dto)) + assertThatThrownBy(() -> userService.adminUpdateUser(UUID.randomUUID(), id, dto)) .isInstanceOf(DomainException.class) .hasMessageContaining("E-Mail"); } @@ -565,7 +565,7 @@ class UserServiceTest { AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); dto.setContact(null); - AppUser result = userService.adminUpdateUser(id, dto); + AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto); assertThat(result.getContact()).isNull(); } @@ -580,7 +580,7 @@ class UserServiceTest { AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); dto.setContact(" "); - AppUser result = userService.adminUpdateUser(id, dto); + AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto); assertThat(result.getContact()).isNull(); } @@ -595,7 +595,7 @@ class UserServiceTest { AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); dto.setContact(" phone: 555 "); - AppUser result = userService.adminUpdateUser(id, dto); + AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto); assertThat(result.getContact()).isEqualTo("phone: 555"); } @@ -610,7 +610,7 @@ class UserServiceTest { AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); dto.setEmail(null); - AppUser result = userService.adminUpdateUser(id, dto); + AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto); assertThat(result.getEmail()).isEqualTo("keep@example.com"); } @@ -626,7 +626,7 @@ class UserServiceTest { AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); dto.setEmail("me@example.com"); - AppUser result = userService.adminUpdateUser(id, dto); + AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto); assertThat(result.getEmail()).isEqualTo("me@example.com"); } @@ -703,6 +703,72 @@ class UserServiceTest { assertThat(result).containsExactly(g); } + // ─── audit: GROUP_MEMBERSHIP_CHANGED ───────────────────────────────────── + + @Test + void adminUpdateUser_logsGroupMembershipChanged_whenGroupSetChanges() { + UUID actorId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + UserGroup oldGroup = UserGroup.builder().id(UUID.randomUUID()).name("Viewers").permissions(Set.of("READ_ALL")).build(); + UserGroup newGroup = UserGroup.builder().id(UUID.randomUUID()).name("Editors").permissions(Set.of("WRITE_ALL")).build(); + AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(oldGroup)).build(); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(groupRepository.findAllById(List.of(newGroup.getId()))).thenReturn(List.of(newGroup)); + when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); + dto.setGroupIds(List.of(newGroup.getId())); + + userService.adminUpdateUser(actorId, userId, dto); + + @SuppressWarnings("unchecked") + ArgumentCaptor> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class); + verify(auditService).logAfterCommit( + org.mockito.ArgumentMatchers.eq(AuditKind.GROUP_MEMBERSHIP_CHANGED), + org.mockito.ArgumentMatchers.eq(actorId), + org.mockito.ArgumentMatchers.isNull(), + payloadCaptor.capture()); + java.util.Map payload = payloadCaptor.getValue(); + assertThat(payload).containsEntry("email", "u@example.com"); + assertThat((java.util.List) payload.get("addedGroups")).containsExactly("Editors"); + assertThat((java.util.List) payload.get("removedGroups")).containsExactly("Viewers"); + } + + @Test + void adminUpdateUser_doesNotLogGroupMembershipChanged_whenGroupsUnchanged() { + UUID actorId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins").build(); + AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(group)).build(); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(groupRepository.findAllById(List.of(group.getId()))).thenReturn(List.of(group)); + when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); + dto.setGroupIds(List.of(group.getId())); + + userService.adminUpdateUser(actorId, userId, dto); + + verify(auditService, never()).logAfterCommit(any(), any(), any(), any()); + } + + @Test + void adminUpdateUser_doesNotLogGroupMembershipChanged_whenGroupIdsIsNull() { + UUID actorId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins").build(); + AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(group)).build(); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); + // groupIds not set → null + + userService.adminUpdateUser(actorId, userId, dto); + + verify(auditService, never()).logAfterCommit(any(), any(), any(), any()); + } + // ─── audit: USER_DELETED ────────────────────────────────────────────────── @Test -- 2.49.1 From 36529f7e11499e1ddfe5b125cb55ffa4a0814315 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 26 Apr 2026 14:57:41 +0200 Subject: [PATCH 04/14] feat(audit): add findRecentUserManagementEvents query method Adds findRecentByKinds JPQL query to AuditLogQueryRepository and findRecentUserManagementEvents(int limit) to AuditLogQueryService, returning the N most recent USER_CREATED/USER_DELETED/GROUP_MEMBERSHIP_CHANGED events ordered newest-first. Co-Authored-By: Claude Sonnet 4.6 --- .../audit/AuditLogQueryRepository.java | 4 ++++ .../audit/AuditLogQueryService.java | 8 +++++++ .../audit/AuditLogQueryServiceTest.java | 21 +++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java index c502a0db..b04e39f6 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java @@ -197,4 +197,8 @@ public interface AuditLogQueryRepository extends JpaRepository { ORDER BY ranked.document_id, ranked.rn """, nativeQuery = true) List findRecentContributorsForDocuments(@Param("documentIds") List documentIds); + + @Query("SELECT a FROM AuditLog a WHERE a.kind IN :kinds ORDER BY a.happenedAt DESC LIMIT :limit") + List findRecentByKinds(@Param("kinds") Collection kinds, + @Param("limit") int limit); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java index 192795bc..5073b748 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java @@ -6,6 +6,10 @@ import org.springframework.stereotype.Service; import java.time.OffsetDateTime; import java.util.*; +import static org.raddatz.familienarchiv.audit.AuditKind.GROUP_MEMBERSHIP_CHANGED; +import static org.raddatz.familienarchiv.audit.AuditKind.USER_CREATED; +import static org.raddatz.familienarchiv.audit.AuditKind.USER_DELETED; + @Service @RequiredArgsConstructor public class AuditLogQueryService { @@ -51,6 +55,10 @@ public class AuditLogQueryService { return toContributorMap(queryRepository.findRecentContributorsForDocuments(documentIds)); } + public List findRecentUserManagementEvents(int limit) { + return queryRepository.findRecentByKinds(Set.of(USER_CREATED, USER_DELETED, GROUP_MEMBERSHIP_CHANGED), limit); + } + private Map> toContributorMap(List rows) { Map> result = new LinkedHashMap<>(); for (ContributorRow row : rows) { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/audit/AuditLogQueryServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/audit/AuditLogQueryServiceTest.java index 4c469deb..356c5ded 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/audit/AuditLogQueryServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/audit/AuditLogQueryServiceTest.java @@ -6,12 +6,17 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.model.AppUser; + +import java.util.Collection; import java.util.List; import java.util.Set; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -47,4 +52,20 @@ class AuditLogQueryServiceTest { verify(queryRepository).findRolledUpActivityFeed(eq(userId.toString()), eq(10), eq(AuditKind.ROLLUP_ELIGIBLE.stream().map(Enum::name).toList())); } + + @Test + void findRecentUserManagementEvents_delegatesToRepositoryWithAllThreeKinds() { + AuditLog entry = AuditLog.builder().id(UUID.randomUUID()).kind(AuditKind.USER_CREATED).build(); + when(queryRepository.findRecentByKinds(anyCollection(), eq(5))).thenReturn(List.of(entry)); + + List result = auditLogQueryService.findRecentUserManagementEvents(5); + + assertThat(result).containsExactly(entry); + verify(queryRepository).findRecentByKinds( + argThat((Collection kinds) -> + kinds.contains(AuditKind.USER_CREATED) && + kinds.contains(AuditKind.USER_DELETED) && + kinds.contains(AuditKind.GROUP_MEMBERSHIP_CHANGED)), + eq(5)); + } } -- 2.49.1 From 77affcfb4faff070d44b2520eb710a2e91e6de9c Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 26 Apr 2026 15:05:09 +0200 Subject: [PATCH 05/14] =?UTF-8?q?test(audit):=20integration=20test=20?= =?UTF-8?q?=E2=80=94=20create=20+=20delete=20user=20produces=20ordered=20a?= =?UTF-8?q?udit=20entries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creates a real actor user first (needed for audit_log FK constraint), then creates and deletes a target user, asserts USER_DELETED is newest and USER_CREATED is second via findRecentUserManagementEvents. Co-Authored-By: Claude Sonnet 4.6 --- .../UserManagementAuditIntegrationTest.java | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/audit/UserManagementAuditIntegrationTest.java diff --git a/backend/src/test/java/org/raddatz/familienarchiv/audit/UserManagementAuditIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/audit/UserManagementAuditIntegrationTest.java new file mode 100644 index 00000000..1437bc83 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/audit/UserManagementAuditIntegrationTest.java @@ -0,0 +1,75 @@ +package org.raddatz.familienarchiv.audit; + +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.dto.CreateUserRequest; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.repository.AppUserRepository; +import org.raddatz.familienarchiv.service.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.support.TransactionTemplate; +import software.amazon.awssdk.services.s3.S3Client; + +import java.util.List; +import java.util.UUID; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("test") +@Import(PostgresContainerConfig.class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +class UserManagementAuditIntegrationTest { + + @MockitoBean S3Client s3Client; + @Autowired UserService userService; + @Autowired AppUserRepository userRepository; + @Autowired AuditLogRepository auditLogRepository; + @Autowired AuditLogQueryService auditLogQueryService; + @Autowired TransactionTemplate transactionTemplate; + + @Test + void createAndDeleteUser_producesOrderedAuditEntries() { + // Create the actor (admin) user directly — bypasses audit logging so no FK issue + CreateUserRequest adminReq = new CreateUserRequest(); + adminReq.setEmail("admin@test.example.com"); + adminReq.setInitialPassword("admin-secret"); + AppUser actor = transactionTemplate.execute(status -> + userService.createUserOrUpdate(null, adminReq)); + UUID actorId = actor.getId(); + + // The admin creation is logged with null actorId — clear to start with a clean slate + await().atMost(5, SECONDS).until(() -> auditLogRepository.count() > 0); + transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; }); + + // Create the target user — should emit USER_CREATED + CreateUserRequest req = new CreateUserRequest(); + req.setEmail("audit-test@example.com"); + req.setInitialPassword("secret"); + transactionTemplate.execute(status -> { + userService.createUserOrUpdate(actorId, req); + return null; + }); + await().atMost(5, SECONDS).until(() -> auditLogRepository.count() > 0); + + // Delete the target user — should emit USER_DELETED + AppUser created = userRepository.findByEmail("audit-test@example.com").orElseThrow(); + transactionTemplate.execute(status -> { + userService.deleteUser(actorId, created.getId()); + return null; + }); + await().atMost(5, SECONDS).until(() -> auditLogRepository.count() >= 2); + + List events = auditLogQueryService.findRecentUserManagementEvents(10); + assertThat(events).hasSize(2); + assertThat(events.get(0).getKind()).isEqualTo(AuditKind.USER_DELETED); + assertThat(events.get(1).getKind()).isEqualTo(AuditKind.USER_CREATED); + } +} -- 2.49.1 From 1d3a3b3338061ec91a8dc6f2b189640964ff753b Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 26 Apr 2026 15:33:30 +0200 Subject: [PATCH 06/14] refactor(audit): extract groupChangePayload() from adminUpdateUser Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/service/UserService.java | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java index 251acae4..eb136f71 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java @@ -183,27 +183,27 @@ public class UserService { } if (dto.getGroupIds() != null) { - Set beforeIds = user.getGroups().stream().map(UserGroup::getId).collect(toSet()); - Set beforeGroups = new HashSet<>(user.getGroups()); - Set newGroups = new HashSet<>(groupRepository.findAllById(dto.getGroupIds())); - user.setGroups(newGroups); - Set afterIds = newGroups.stream().map(UserGroup::getId).collect(toSet()); - if (!beforeIds.equals(afterIds)) { - List added = newGroups.stream() - .filter(g -> !beforeIds.contains(g.getId())) - .map(UserGroup::getName).toList(); - List removed = beforeGroups.stream() - .filter(g -> !afterIds.contains(g.getId())) - .map(UserGroup::getName).toList(); - auditService.logAfterCommit(AuditKind.GROUP_MEMBERSHIP_CHANGED, actorId, null, - Map.of("userId", id.toString(), "email", user.getEmail(), - "addedGroups", added, "removedGroups", removed)); - } + Set before = new HashSet<>(user.getGroups()); + Set after = new HashSet<>(groupRepository.findAllById(dto.getGroupIds())); + user.setGroups(after); + groupChangePayload(before, after, id, user.getEmail()) + .ifPresent(payload -> auditService.logAfterCommit(AuditKind.GROUP_MEMBERSHIP_CHANGED, actorId, null, payload)); } return userRepository.save(user); } + private Optional> groupChangePayload( + Set before, Set after, UUID userId, String email) { + Set beforeIds = before.stream().map(UserGroup::getId).collect(toSet()); + Set afterIds = after.stream().map(UserGroup::getId).collect(toSet()); + if (beforeIds.equals(afterIds)) return Optional.empty(); + List added = after.stream().filter(g -> !beforeIds.contains(g.getId())).map(UserGroup::getName).toList(); + List removed = before.stream().filter(g -> !afterIds.contains(g.getId())).map(UserGroup::getName).toList(); + return Optional.of(Map.of("userId", userId.toString(), "email", email, + "addedGroups", added, "removedGroups", removed)); + } + @Transactional public void changePassword(UUID userId, ChangePasswordDTO dto) { AppUser user = getById(userId); -- 2.49.1 From f4a4436eda83c79397c5c1f3783c08bd5e4f60be Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 26 Apr 2026 15:35:42 +0200 Subject: [PATCH 07/14] test(audit): add 403 permission tests for createUser, adminUpdateUser, deleteUser Co-Authored-By: Claude Sonnet 4.6 --- .../controller/UserControllerTest.java | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) 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 2b330a83..a0fca09c 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/UserControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/UserControllerTest.java @@ -18,8 +18,10 @@ 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.delete; 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.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -104,4 +106,31 @@ class UserControllerTest { .content("{\"email\":\"\",\"initialPassword\":\"secret123\"}")) .andExpect(status().isBadRequest()); } + + // ─── permission enforcement ─────────────────────────────────────────────── + + @Test + @WithMockUser(username = "reader@example.com") + void createUser_returns403_whenCallerLacksAdminUserPermission() throws Exception { + mockMvc.perform(post("/api/users") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(username = "reader@example.com") + void adminUpdateUser_returns403_whenCallerLacksAdminUserPermission() throws Exception { + mockMvc.perform(put("/api/users/" + UUID.randomUUID()) + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(username = "reader@example.com") + void deleteUser_returns403_whenCallerLacksAdminUserPermission() throws Exception { + mockMvc.perform(delete("/api/users/" + UUID.randomUUID())) + .andExpect(status().isForbidden()); + } } -- 2.49.1 From f6bcc4f72a46dca358e29c2e2ba865bf95f0bd7d Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 26 Apr 2026 15:37:50 +0200 Subject: [PATCH 08/14] refactor(audit): extract actorId() helper in UserController Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/controller/UserController.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 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 b3753546..b52150f2 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java @@ -80,8 +80,7 @@ public class UserController { @RequirePermission(Permission.ADMIN_USER) public ResponseEntity createUser(Authentication authentication, @Valid @RequestBody CreateUserRequest request) { - AppUser actor = userService.findByEmail(authentication.getName()); - return ResponseEntity.ok(userService.createUserOrUpdate(actor.getId(), request)); + return ResponseEntity.ok(userService.createUserOrUpdate(actorId(authentication), request)); } @PutMapping("/users/{id}") @@ -89,8 +88,7 @@ public class UserController { public ResponseEntity adminUpdateUser(Authentication authentication, @PathVariable UUID id, @RequestBody AdminUpdateUserRequest dto) { - AppUser actor = userService.findByEmail(authentication.getName()); - AppUser updated = userService.adminUpdateUser(actor.getId(), id, dto); + AppUser updated = userService.adminUpdateUser(actorId(authentication), id, dto); updated.setPassword(null); return ResponseEntity.ok(updated); } @@ -99,9 +97,12 @@ public class UserController { @RequirePermission(Permission.ADMIN_USER) public ResponseEntity deleteUser(Authentication authentication, @PathVariable UUID id) { - AppUser actor = userService.findByEmail(authentication.getName()); - userService.deleteUser(actor.getId(), id); + userService.deleteUser(actorId(authentication), id); return ResponseEntity.ok().build(); } + private UUID actorId(Authentication auth) { + return userService.findByEmail(auth.getName()).getId(); + } + } -- 2.49.1 From 11d93919b2d9d9f2d1440e88f163131ba2cbe0d2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 26 Apr 2026 15:40:53 +0200 Subject: [PATCH 09/14] refactor(audit): replace LIMIT :limit JPQL with Pageable in audit query Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/audit/AuditLogQueryRepository.java | 6 +++--- .../familienarchiv/audit/AuditLogQueryService.java | 5 ++++- .../audit/AuditLogQueryServiceTest.java | 11 +++++++---- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java index b04e39f6..8984d465 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java @@ -1,5 +1,7 @@ package org.raddatz.familienarchiv.audit; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -198,7 +200,5 @@ public interface AuditLogQueryRepository extends JpaRepository { """, nativeQuery = true) List findRecentContributorsForDocuments(@Param("documentIds") List documentIds); - @Query("SELECT a FROM AuditLog a WHERE a.kind IN :kinds ORDER BY a.happenedAt DESC LIMIT :limit") - List findRecentByKinds(@Param("kinds") Collection kinds, - @Param("limit") int limit); + Page findByKindIn(Collection kinds, Pageable pageable); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java index 5073b748..f1fcb5b1 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java @@ -1,6 +1,8 @@ package org.raddatz.familienarchiv.audit; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import java.time.OffsetDateTime; @@ -56,7 +58,8 @@ public class AuditLogQueryService { } public List findRecentUserManagementEvents(int limit) { - return queryRepository.findRecentByKinds(Set.of(USER_CREATED, USER_DELETED, GROUP_MEMBERSHIP_CHANGED), limit); + PageRequest page = PageRequest.of(0, limit, Sort.by("happenedAt").descending()); + return queryRepository.findByKindIn(Set.of(USER_CREATED, USER_DELETED, GROUP_MEMBERSHIP_CHANGED), page).getContent(); } private Map> toContributorMap(List rows) { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/audit/AuditLogQueryServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/audit/AuditLogQueryServiceTest.java index 356c5ded..c7e78a3c 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/audit/AuditLogQueryServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/audit/AuditLogQueryServiceTest.java @@ -7,6 +7,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.raddatz.familienarchiv.model.AppUser; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; import java.util.Collection; import java.util.List; @@ -14,8 +16,8 @@ import java.util.Set; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyCollection; -import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; @@ -56,16 +58,17 @@ class AuditLogQueryServiceTest { @Test void findRecentUserManagementEvents_delegatesToRepositoryWithAllThreeKinds() { AuditLog entry = AuditLog.builder().id(UUID.randomUUID()).kind(AuditKind.USER_CREATED).build(); - when(queryRepository.findRecentByKinds(anyCollection(), eq(5))).thenReturn(List.of(entry)); + when(queryRepository.findByKindIn(anyCollection(), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(entry))); List result = auditLogQueryService.findRecentUserManagementEvents(5); assertThat(result).containsExactly(entry); - verify(queryRepository).findRecentByKinds( + verify(queryRepository).findByKindIn( argThat((Collection kinds) -> kinds.contains(AuditKind.USER_CREATED) && kinds.contains(AuditKind.USER_DELETED) && kinds.contains(AuditKind.GROUP_MEMBERSHIP_CHANGED)), - eq(5)); + any(Pageable.class)); } } -- 2.49.1 From 23cff1cdd756153cd4d9720a51a2ff0ce1d0026b Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 26 Apr 2026 15:43:07 +0200 Subject: [PATCH 10/14] refactor(audit): drop @DirtiesContext, add @BeforeEach, use existsByKind in wait conditions Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/audit/AuditLogRepository.java | 1 + .../audit/UserManagementAuditIntegrationTest.java | 14 +++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogRepository.java index 2322c8c9..794d2a29 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogRepository.java @@ -5,4 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.UUID; public interface AuditLogRepository extends JpaRepository { + boolean existsByKind(AuditKind kind); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/audit/UserManagementAuditIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/audit/UserManagementAuditIntegrationTest.java index 1437bc83..806b6ff6 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/audit/UserManagementAuditIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/audit/UserManagementAuditIntegrationTest.java @@ -1,5 +1,6 @@ package org.raddatz.familienarchiv.audit; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.raddatz.familienarchiv.PostgresContainerConfig; import org.raddatz.familienarchiv.dto.CreateUserRequest; @@ -9,7 +10,6 @@ import org.raddatz.familienarchiv.service.UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; -import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.transaction.support.TransactionTemplate; @@ -25,7 +25,6 @@ import static org.awaitility.Awaitility.await; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) @ActiveProfiles("test") @Import(PostgresContainerConfig.class) -@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) class UserManagementAuditIntegrationTest { @MockitoBean S3Client s3Client; @@ -35,6 +34,11 @@ class UserManagementAuditIntegrationTest { @Autowired AuditLogQueryService auditLogQueryService; @Autowired TransactionTemplate transactionTemplate; + @BeforeEach + void clearAuditLog() { + transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; }); + } + @Test void createAndDeleteUser_producesOrderedAuditEntries() { // Create the actor (admin) user directly — bypasses audit logging so no FK issue @@ -46,7 +50,7 @@ class UserManagementAuditIntegrationTest { UUID actorId = actor.getId(); // The admin creation is logged with null actorId — clear to start with a clean slate - await().atMost(5, SECONDS).until(() -> auditLogRepository.count() > 0); + await().atMost(5, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED)); transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; }); // Create the target user — should emit USER_CREATED @@ -57,7 +61,7 @@ class UserManagementAuditIntegrationTest { userService.createUserOrUpdate(actorId, req); return null; }); - await().atMost(5, SECONDS).until(() -> auditLogRepository.count() > 0); + await().atMost(5, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED)); // Delete the target user — should emit USER_DELETED AppUser created = userRepository.findByEmail("audit-test@example.com").orElseThrow(); @@ -65,7 +69,7 @@ class UserManagementAuditIntegrationTest { userService.deleteUser(actorId, created.getId()); return null; }); - await().atMost(5, SECONDS).until(() -> auditLogRepository.count() >= 2); + await().atMost(5, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_DELETED)); List events = auditLogQueryService.findRecentUserManagementEvents(10); assertThat(events).hasSize(2); -- 2.49.1 From 1dd6e054fcebc4b5155a71e3de56ded306df7efe Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 26 Apr 2026 15:46:47 +0200 Subject: [PATCH 11/14] test(audit): add GROUP_MEMBERSHIP_CHANGED integration test with payload assertions Co-Authored-By: Claude Sonnet 4.6 --- .../UserManagementAuditIntegrationTest.java | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/audit/UserManagementAuditIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/audit/UserManagementAuditIntegrationTest.java index 806b6ff6..f827d186 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/audit/UserManagementAuditIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/audit/UserManagementAuditIntegrationTest.java @@ -3,8 +3,11 @@ package org.raddatz.familienarchiv.audit; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest; import org.raddatz.familienarchiv.dto.CreateUserRequest; +import org.raddatz.familienarchiv.dto.GroupDTO; import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.UserGroup; import org.raddatz.familienarchiv.repository.AppUserRepository; import org.raddatz.familienarchiv.service.UserService; import org.springframework.beans.factory.annotation.Autowired; @@ -16,6 +19,7 @@ import org.springframework.transaction.support.TransactionTemplate; import software.amazon.awssdk.services.s3.S3Client; import java.util.List; +import java.util.Set; import java.util.UUID; import static java.util.concurrent.TimeUnit.SECONDS; @@ -76,4 +80,51 @@ class UserManagementAuditIntegrationTest { assertThat(events.get(0).getKind()).isEqualTo(AuditKind.USER_DELETED); assertThat(events.get(1).getKind()).isEqualTo(AuditKind.USER_CREATED); } + + @Test + void updateUserGroups_producesGroupMembershipChangedEvent() { + // Create groups before creating users — required for group assignment on creation + GroupDTO groupADto = new GroupDTO(); groupADto.setName("Viewers"); groupADto.setPermissions(Set.of("READ_ALL")); + GroupDTO groupBDto = new GroupDTO(); groupBDto.setName("Editors"); groupBDto.setPermissions(Set.of("WRITE_ALL")); + UserGroup gA = transactionTemplate.execute(status -> userService.createGroup(groupADto)); + UserGroup gB = transactionTemplate.execute(status -> userService.createGroup(groupBDto)); + + // Create actor (bootstrap — null actorId, event not relevant) + CreateUserRequest actorReq = new CreateUserRequest(); + actorReq.setEmail("actor-group-test@test.example.com"); + actorReq.setInitialPassword("secret"); + AppUser actor = transactionTemplate.execute(status -> userService.createUserOrUpdate(null, actorReq)); + await().atMost(5, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED)); + transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; }); + + // Create target user pre-assigned to gA + CreateUserRequest targetReq = new CreateUserRequest(); + targetReq.setEmail("target-group-test@test.example.com"); + targetReq.setInitialPassword("secret"); + targetReq.setGroupIds(List.of(gA.getId())); + transactionTemplate.execute(status -> userService.createUserOrUpdate(actor.getId(), targetReq)); + await().atMost(5, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED)); + transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; }); + + AppUser target = userRepository.findByEmail("target-group-test@test.example.com").orElseThrow(); + + // Change groups: Viewers → Editors + AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); + dto.setGroupIds(List.of(gB.getId())); + transactionTemplate.execute(status -> userService.adminUpdateUser(actor.getId(), target.getId(), dto)); + + await().atMost(5, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.GROUP_MEMBERSHIP_CHANGED)); + + List events = auditLogQueryService.findRecentUserManagementEvents(10); + assertThat(events).hasSize(1); + AuditLog event = events.get(0); + assertThat(event.getKind()).isEqualTo(AuditKind.GROUP_MEMBERSHIP_CHANGED); + assertThat(event.getPayload()).containsEntry("email", "target-group-test@test.example.com"); + @SuppressWarnings("unchecked") + List added = (List) event.getPayload().get("addedGroups"); + @SuppressWarnings("unchecked") + List removed = (List) event.getPayload().get("removedGroups"); + assertThat(added).containsExactlyInAnyOrder("Editors"); + assertThat(removed).containsExactlyInAnyOrder("Viewers"); + } } -- 2.49.1 From 6d9910b8057f197ef5e60c6561268e01fb66a371 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 26 Apr 2026 17:39:09 +0200 Subject: [PATCH 12/14] refactor(audit): extract createUserForBootstrap() to make null actorId contract explicit createUserOrUpdate(UUID actorId, ...) is always called from the controller with a real authenticated actor. createUserForBootstrap() handles seeding/test setup without emitting an audit event, making the two contracts unambiguous. Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/service/UserService.java | 28 +++++++++++++++++++ .../service/UserServiceTest.java | 20 +++++++++++++ 2 files changed, 48 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java index eb136f71..971be5b3 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java @@ -80,6 +80,34 @@ public class UserService { return saved; } + @Transactional + public AppUser createUserForBootstrap(CreateUserRequest request) { + log.info("Bootstrap user creation (no audit): {}", request.getEmail()); + + Set groups = new HashSet<>(); + if (request.getGroupIds() != null && !request.getGroupIds().isEmpty()) { + groups.addAll(groupRepository.findAllById(request.getGroupIds())); + } + + Optional existingUser = userRepository.findByEmail(request.getEmail()); + if (existingUser.isPresent()) { + AppUser updated = existingUser.get().updateFromRequest(request, passwordEncoder, groups); + return userRepository.save(updated); + } + + AppUser user = AppUser.builder() + .email(request.getEmail()) + .password(passwordEncoder.encode(request.getInitialPassword())) + .groups(groups) + .firstName(request.getFirstName()) + .lastName(request.getLastName()) + .birthDate(request.getBirthDate()) + .contact(request.getContact()) + .enabled(true) + .build(); + return userRepository.save(user); + } + @Transactional public AppUser createUser(String email, String rawPassword, String firstName, String lastName, Set groupIds) { userRepository.findByEmail(email).ifPresent(existing -> { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java index 2ff5ce27..c146323f 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java @@ -837,6 +837,26 @@ class UserServiceTest { verify(auditService, never()).logAfterCommit(any(), any(), any(), any()); } + // ─── createUserForBootstrap ─────────────────────────────────────────────── + + @Test + void createUserForBootstrap_createsUserWithoutAuditEvent() { + CreateUserRequest req = new CreateUserRequest(); + req.setEmail("bootstrap@example.com"); + req.setInitialPassword("secret"); + req.setGroupIds(List.of()); + + when(userRepository.findByEmail("bootstrap@example.com")).thenReturn(Optional.empty()); + when(passwordEncoder.encode("secret")).thenReturn("encoded"); + AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("bootstrap@example.com").build(); + when(userRepository.save(any())).thenReturn(saved); + + AppUser result = userService.createUserForBootstrap(req); + + assertThat(result).isEqualTo(saved); + verify(auditService, never()).logAfterCommit(any(), any(), any(), any()); + } + // ─── createGroup ────────────────────────────────────────────────────────── @Test -- 2.49.1 From a6c8af09717b94db85707bdfe7bbad685c62ce8c Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 26 Apr 2026 17:41:56 +0200 Subject: [PATCH 13/14] test(audit): replace null-actorId bootstrap calls with createUserForBootstrap(), increase timeouts to 10s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the wait+clear cycles that existed only to drain the audit events emitted by createUserOrUpdate(null, ...). Timeouts increased 5 → 10 s to reduce CI flakiness under load. Co-Authored-By: Claude Sonnet 4.6 --- .../UserManagementAuditIntegrationTest.java | 26 +++++++------------ 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/audit/UserManagementAuditIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/audit/UserManagementAuditIntegrationTest.java index f827d186..6eeda488 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/audit/UserManagementAuditIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/audit/UserManagementAuditIntegrationTest.java @@ -45,18 +45,13 @@ class UserManagementAuditIntegrationTest { @Test void createAndDeleteUser_producesOrderedAuditEntries() { - // Create the actor (admin) user directly — bypasses audit logging so no FK issue + // Bootstrap actor with no audit event — clean slate guaranteed by @BeforeEach CreateUserRequest adminReq = new CreateUserRequest(); adminReq.setEmail("admin@test.example.com"); adminReq.setInitialPassword("admin-secret"); - AppUser actor = transactionTemplate.execute(status -> - userService.createUserOrUpdate(null, adminReq)); + AppUser actor = transactionTemplate.execute(status -> userService.createUserForBootstrap(adminReq)); UUID actorId = actor.getId(); - // The admin creation is logged with null actorId — clear to start with a clean slate - await().atMost(5, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED)); - transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; }); - // Create the target user — should emit USER_CREATED CreateUserRequest req = new CreateUserRequest(); req.setEmail("audit-test@example.com"); @@ -65,7 +60,7 @@ class UserManagementAuditIntegrationTest { userService.createUserOrUpdate(actorId, req); return null; }); - await().atMost(5, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED)); + await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED)); // Delete the target user — should emit USER_DELETED AppUser created = userRepository.findByEmail("audit-test@example.com").orElseThrow(); @@ -73,7 +68,7 @@ class UserManagementAuditIntegrationTest { userService.deleteUser(actorId, created.getId()); return null; }); - await().atMost(5, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_DELETED)); + await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_DELETED)); List events = auditLogQueryService.findRecentUserManagementEvents(10); assertThat(events).hasSize(2); @@ -83,27 +78,24 @@ class UserManagementAuditIntegrationTest { @Test void updateUserGroups_producesGroupMembershipChangedEvent() { - // Create groups before creating users — required for group assignment on creation GroupDTO groupADto = new GroupDTO(); groupADto.setName("Viewers"); groupADto.setPermissions(Set.of("READ_ALL")); GroupDTO groupBDto = new GroupDTO(); groupBDto.setName("Editors"); groupBDto.setPermissions(Set.of("WRITE_ALL")); UserGroup gA = transactionTemplate.execute(status -> userService.createGroup(groupADto)); UserGroup gB = transactionTemplate.execute(status -> userService.createGroup(groupBDto)); - // Create actor (bootstrap — null actorId, event not relevant) + // Bootstrap actor with no audit event — clean slate guaranteed by @BeforeEach CreateUserRequest actorReq = new CreateUserRequest(); actorReq.setEmail("actor-group-test@test.example.com"); actorReq.setInitialPassword("secret"); - AppUser actor = transactionTemplate.execute(status -> userService.createUserOrUpdate(null, actorReq)); - await().atMost(5, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED)); - transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; }); + AppUser actor = transactionTemplate.execute(status -> userService.createUserForBootstrap(actorReq)); - // Create target user pre-assigned to gA + // Create target user pre-assigned to gA — emits USER_CREATED CreateUserRequest targetReq = new CreateUserRequest(); targetReq.setEmail("target-group-test@test.example.com"); targetReq.setInitialPassword("secret"); targetReq.setGroupIds(List.of(gA.getId())); transactionTemplate.execute(status -> userService.createUserOrUpdate(actor.getId(), targetReq)); - await().atMost(5, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED)); + await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED)); transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; }); AppUser target = userRepository.findByEmail("target-group-test@test.example.com").orElseThrow(); @@ -113,7 +105,7 @@ class UserManagementAuditIntegrationTest { dto.setGroupIds(List.of(gB.getId())); transactionTemplate.execute(status -> userService.adminUpdateUser(actor.getId(), target.getId(), dto)); - await().atMost(5, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.GROUP_MEMBERSHIP_CHANGED)); + await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.GROUP_MEMBERSHIP_CHANGED)); List events = auditLogQueryService.findRecentUserManagementEvents(10); assertThat(events).hasSize(1); -- 2.49.1 From ce41e96a45f878ef6025b399afeab57b35d7108b Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 26 Apr 2026 17:44:03 +0200 Subject: [PATCH 14/14] test(audit): add 401 unauthenticated tests for createUser, adminUpdateUser, deleteUser Regression guards verifying that Spring Security returns 401 (not 200) when no credentials are provided, complementing the existing 403 permission tests. Co-Authored-By: Claude Sonnet 4.6 --- .../controller/UserControllerTest.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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 a0fca09c..d4686ab4 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/UserControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/UserControllerTest.java @@ -133,4 +133,28 @@ class UserControllerTest { mockMvc.perform(delete("/api/users/" + UUID.randomUUID())) .andExpect(status().isForbidden()); } + + // ─── unauthenticated access ─────────────────────────────────────────────── + + @Test + void createUser_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/api/users") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}")) + .andExpect(status().isUnauthorized()); + } + + @Test + void adminUpdateUser_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(put("/api/users/" + UUID.randomUUID()) + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isUnauthorized()); + } + + @Test + void deleteUser_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(delete("/api/users/" + UUID.randomUUID())) + .andExpect(status().isUnauthorized()); + } } -- 2.49.1