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/audit/AuditLogQueryRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java index c502a0db..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; @@ -197,4 +199,6 @@ public interface AuditLogQueryRepository extends JpaRepository { ORDER BY ranked.document_id, ranked.rn """, nativeQuery = true) List findRecentContributorsForDocuments(@Param("documentIds") List documentIds); + + 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 192795bc..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,11 +1,17 @@ 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; 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 +57,11 @@ public class AuditLogQueryService { return toContributorMap(queryRepository.findRecentContributorsForDocuments(documentIds)); } + public List findRecentUserManagementEvents(int 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) { Map> result = new LinkedHashMap<>(); for (ContributorRow row : rows) { 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/main/java/org/raddatz/familienarchiv/controller/UserController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java index 1813c37d..b52150f2 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java @@ -78,24 +78,31 @@ 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) { + return ResponseEntity.ok(userService.createUserOrUpdate(actorId(authentication), request)); } @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 updated = userService.adminUpdateUser(actorId(authentication), id, dto); updated.setPassword(null); return ResponseEntity.ok(updated); } @DeleteMapping("/users/{id}") @RequirePermission(Permission.ADMIN_USER) - public ResponseEntity deleteUser(@PathVariable UUID id) { - userService.deleteUser(id); + public ResponseEntity deleteUser(Authentication authentication, + @PathVariable UUID id) { + userService.deleteUser(actorId(authentication), id); return ResponseEntity.ok().build(); } + private UUID actorId(Authentication auth) { + return userService.findByEmail(auth.getName()).getId(); + } + } 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..971be5b3 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,10 +23,13 @@ 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; +import static java.util.stream.Collectors.toSet; + @Service @RequiredArgsConstructor @Slf4j @@ -33,9 +38,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 +51,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,8 +69,42 @@ public class UserService { .contact(request.getContact()) .enabled(true) .build(); + isNew = true; } + 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 + 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); } @@ -94,10 +136,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) { @@ -141,7 +186,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()) { @@ -166,13 +211,27 @@ public class UserService { } if (dto.getGroupIds() != null) { - Set groups = new HashSet<>(groupRepository.findAllById(dto.getGroupIds())); - user.setGroups(groups); + 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); 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..c7e78a3c 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,19 @@ import org.mockito.InjectMocks; 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; 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.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -47,4 +54,21 @@ 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.findByKindIn(anyCollection(), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(entry))); + + List result = auditLogQueryService.findRecentUserManagementEvents(5); + + assertThat(result).containsExactly(entry); + verify(queryRepository).findByKindIn( + argThat((Collection kinds) -> + kinds.contains(AuditKind.USER_CREATED) && + kinds.contains(AuditKind.USER_DELETED) && + kinds.contains(AuditKind.GROUP_MEMBERSHIP_CHANGED)), + any(Pageable.class)); + } } 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..6eeda488 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/audit/UserManagementAuditIntegrationTest.java @@ -0,0 +1,122 @@ +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; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +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.Set; +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) +class UserManagementAuditIntegrationTest { + + @MockitoBean S3Client s3Client; + @Autowired UserService userService; + @Autowired AppUserRepository userRepository; + @Autowired AuditLogRepository auditLogRepository; + @Autowired AuditLogQueryService auditLogQueryService; + @Autowired TransactionTemplate transactionTemplate; + + @BeforeEach + void clearAuditLog() { + transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; }); + } + + @Test + void createAndDeleteUser_producesOrderedAuditEntries() { + // 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.createUserForBootstrap(adminReq)); + UUID actorId = actor.getId(); + + // 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(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(); + transactionTemplate.execute(status -> { + userService.deleteUser(actorId, created.getId()); + return null; + }); + await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_DELETED)); + + 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); + } + + @Test + void updateUserGroups_producesGroupMembershipChangedEvent() { + 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)); + + // 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.createUserForBootstrap(actorReq)); + + // 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(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(); + + // 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(10, 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"); + } +} 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..d4686ab4 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,55 @@ 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()); + } + + // ─── 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()); + } } 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..c146323f 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 ────────────────────────────────────────────────────────── @@ -61,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); } @@ -71,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); } @@ -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); } @@ -229,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"); @@ -246,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); } @@ -264,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); } @@ -281,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(); } @@ -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())); @@ -378,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"); } @@ -393,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()); @@ -408,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"); } @@ -425,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"); } @@ -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()); } @@ -561,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(); } @@ -576,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(); } @@ -591,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"); } @@ -606,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"); } @@ -622,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"); } @@ -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,160 @@ 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 + 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 + 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()); + } + + // ─── 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