diff --git a/src/main/java/org/example/team6backend/admin/AdminController.java b/src/main/java/org/example/team6backend/admin/AdminController.java index 0d8ba22..0157425 100644 --- a/src/main/java/org/example/team6backend/admin/AdminController.java +++ b/src/main/java/org/example/team6backend/admin/AdminController.java @@ -17,10 +17,12 @@ import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; - import java.util.Map; +import org.springframework.security.access.AccessDeniedException; @RestController @RequestMapping("/api/admin") @@ -32,6 +34,17 @@ public class AdminController { private final UserService userService; private final UserMapper userMapper; + private AppUser getCurrentUser(CustomUserDetails currentUser) { + if (currentUser != null && currentUser.getUser() != null) { + return currentUser.getUser(); + } + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getPrincipal() instanceof CustomUserDetails customUserDetails) { + return customUserDetails.getUser(); + } + throw new AccessDeniedException("NNot authenticated!"); + } + @GetMapping("/users") public ResponseEntity> getUsers(@RequestParam(required = false) String email, @RequestParam(required = false) String name, @RequestParam(required = false) UserRole role, @@ -68,9 +81,13 @@ public ResponseEntity getUser(@PathVariable String userId) { } @PostMapping("/users/{userId}/approve") - public ResponseEntity approveUser(@PathVariable String userId) { + public ResponseEntity approveUser(@PathVariable String userId, + @AuthenticationPrincipal CustomUserDetails currentUser) { + AppUser admin = getCurrentUser(currentUser); + log.info("POST /api/admin/users/{}/approve - Approving pending user", userId); - AppUser approvedUser = userService.approvePendingUser(userId); + + AppUser approvedUser = userService.approvePendingUser(userId, admin); log.info("User {} approved successfully. New role: {}", approvedUser.getGithubLogin(), approvedUser.getRole()); return ResponseEntity.ok(userMapper.toResponse(approvedUser)); @@ -80,14 +97,12 @@ public ResponseEntity approveUser(@PathVariable String userId) { public ResponseEntity updateUserRole(@PathVariable String userId, @Valid @RequestBody UpdateUserRoleRequest request, @AuthenticationPrincipal CustomUserDetails currentUser) { - String currentUserLogin = currentUser != null && currentUser.getUser() != null - ? currentUser.getUser().getGithubLogin() - : "unknown"; + AppUser admin = getCurrentUser(currentUser); - log.info("PATCH /api/admin/users/{}/role - Admin {} changing role to {}", userId, currentUserLogin, + log.info("PATCH /api/admin/users/{}/role - Admin {} changing role to {}", userId, admin.getGithubLogin(), request.role()); - if (currentUser != null && currentUser.getUser() != null && currentUser.getUser().getId().equals(userId)) { + if (admin.getId().equals(userId)) { // ← FÖRENKLAT kollen log.warn("User {} attempted to change their own role", userId); throw new IllegalStateException("You cannot change your own role"); } @@ -97,15 +112,15 @@ public ResponseEntity updateUserRole(@PathVariable String userId, if (targetUser.getRole() == UserRole.ADMIN) { long adminCount = userService.getAllUsers().stream().filter(u -> u.getRole() == UserRole.ADMIN).count(); if (adminCount <= 1) { - log.warn("Attempt to remove last admin user {} by {}", userId, currentUserLogin); + log.warn("Attempt to remove last admin user {} by {}", userId, admin.getGithubLogin()); throw new IllegalStateException("Cannot remove the last admin user"); } } } - AppUser updatedUser = userService.updateUserRole(userId, request.role()); + AppUser updatedUser = userService.updateUserRole(userId, request.role(), admin); // ← SKICKA MED ADMIN log.info("User {} role changed to {} by admin {}", updatedUser.getGithubLogin(), request.role(), - currentUserLogin); + admin.getGithubLogin()); return ResponseEntity.ok(userMapper.toResponse(updatedUser)); } @@ -115,22 +130,20 @@ public ResponseEntity updateUserStatus(@PathVariable String userId @Valid @RequestBody UpdateUserStatusRequest request, @AuthenticationPrincipal CustomUserDetails currentUser) { - String currentUserLogin = currentUser != null && currentUser.getUser() != null - ? currentUser.getUser().getGithubLogin() - : "unknown"; + AppUser admin = getCurrentUser(currentUser); - log.info("PATCH /api/admin/users/{}/status - Admin {} setting active={}", userId, currentUserLogin, + log.info("PATCH /api/admin/users/{}/status - Admin {} setting active={}", userId, admin.getGithubLogin(), request.active()); - if (currentUser != null && currentUser.getUser() != null && currentUser.getUser().getId().equals(userId) - && !request.active()) { + if (admin.getId().equals(userId) && !request.active()) { log.warn("User {} attempted to deactivate their own account", userId); throw new IllegalStateException("You cannot deactivate your own account"); } - AppUser updatedUser = userService.updateUserActiveStatus(userId, request.active()); + AppUser updatedUser = userService.updateUserActiveStatus(userId, request.active(), admin); + log.info("User {} active status changed to {} by admin {}", updatedUser.getGithubLogin(), - updatedUser.isActive(), currentUserLogin); + updatedUser.isActive(), admin.getGithubLogin()); return ResponseEntity.ok(userMapper.toResponse(updatedUser)); } @@ -139,21 +152,19 @@ public ResponseEntity updateUserStatus(@PathVariable String userId public ResponseEntity deleteUser(@PathVariable String userId, @AuthenticationPrincipal CustomUserDetails currentUser) { - String currentUserLogin = currentUser != null && currentUser.getUser() != null - ? currentUser.getUser().getGithubLogin() - : "unknown"; + AppUser admin = getCurrentUser(currentUser); - log.info("DELETE /api/admin/users/{} - Admin {} attempting to delete user", userId, currentUserLogin); + log.info("DELETE /api/admin/users/{} - Admin {} attempting to delete user", userId, admin.getGithubLogin()); - if (currentUser != null && currentUser.getUser() != null && currentUser.getUser().getId().equals(userId)) { + if (admin.getId().equals(userId)) { // ← FÖRENKLAT log.warn("User {} attempted to delete their own account", userId); throw new IllegalStateException("You cannot delete your own account"); } var userToDelete = userService.getUserById(userId); - userService.deleteUser(userId); + userService.deleteUser(userId, admin); // ← SKICKA MED ADMIN log.info("User {} ({}) deleted by admin {}", userToDelete.getGithubLogin(), userToDelete.getRole(), - currentUserLogin); + admin.getGithubLogin()); return ResponseEntity.noContent().build(); } diff --git a/src/main/java/org/example/team6backend/auditlog/controller/AuditLogController.java b/src/main/java/org/example/team6backend/auditlog/controller/AuditLogController.java new file mode 100644 index 0000000..7372ee5 --- /dev/null +++ b/src/main/java/org/example/team6backend/auditlog/controller/AuditLogController.java @@ -0,0 +1,39 @@ +package org.example.team6backend.auditlog.controller; + +import lombok.RequiredArgsConstructor; +import org.example.team6backend.auditlog.entity.AuditLog; +import org.example.team6backend.auditlog.repository.AuditLogRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/admin/audit") +@PreAuthorize("hasRole('ADMIN')") +@RequiredArgsConstructor +public class AuditLogController { + + private final AuditLogRepository auditLogRepository; + + @GetMapping + public ResponseEntity> getLogs(@RequestParam(required = false) String search, + @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { + Page logs; + if (search != null && !search.isEmpty()) { + logs = auditLogRepository.searchLogs(search, pageable); + } else { + logs = auditLogRepository.findAll(pageable); + } + return ResponseEntity.ok(logs); + } + + @GetMapping("/user/{userId}") + public ResponseEntity> getLogsByUser(@PathVariable String userId) { + Page logs = auditLogRepository.findByPerformedByIdOrderByCreatedAtDesc(userId); + return ResponseEntity.ok(logs); + } +} diff --git a/src/main/java/org/example/team6backend/auditlog/entity/AuditLog.java b/src/main/java/org/example/team6backend/auditlog/entity/AuditLog.java new file mode 100644 index 0000000..5ec55c4 --- /dev/null +++ b/src/main/java/org/example/team6backend/auditlog/entity/AuditLog.java @@ -0,0 +1,29 @@ +package org.example.team6backend.auditlog.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.example.team6backend.user.entity.AppUser; + +import java.time.Instant; + +@Entity +@Getter +@Setter +public class AuditLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String userName; + private String action; + private String targetType; + private String targetId; + private String details; + private Instant createdAt; + + @ManyToOne + @JoinColumn(name = "user_id") + private AppUser performedBy; +} diff --git a/src/main/java/org/example/team6backend/auditlog/repository/AuditLogRepository.java b/src/main/java/org/example/team6backend/auditlog/repository/AuditLogRepository.java new file mode 100644 index 0000000..21adcd2 --- /dev/null +++ b/src/main/java/org/example/team6backend/auditlog/repository/AuditLogRepository.java @@ -0,0 +1,17 @@ +package org.example.team6backend.auditlog.repository; + +import org.example.team6backend.auditlog.entity.AuditLog; +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; + +public interface AuditLogRepository extends JpaRepository { + + @Query("SELECT a FROM AuditLog a " + "WHERE LOWER(a.performedBy.name) LIKE LOWER(CONCAT('%', :s, '%')) " + + "OR LOWER(a.action) LIKE LOWER(CONCAT('%', :s, '%'))") + Page searchLogs(@Param("s") String search, Pageable pageable); + + Page findByPerformedByIdOrderByCreatedAtDesc(String userId); +} diff --git a/src/main/java/org/example/team6backend/auditlog/service/AuditLogService.java b/src/main/java/org/example/team6backend/auditlog/service/AuditLogService.java new file mode 100644 index 0000000..edefe7e --- /dev/null +++ b/src/main/java/org/example/team6backend/auditlog/service/AuditLogService.java @@ -0,0 +1,40 @@ +package org.example.team6backend.auditlog.service; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.team6backend.auditlog.entity.AuditLog; +import org.example.team6backend.auditlog.repository.AuditLogRepository; +import org.example.team6backend.user.entity.AppUser; +import org.example.team6backend.user.repository.AppUserRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.Instant; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuditLogService { + private final AuditLogRepository auditLogRepository; + private final AppUserRepository appUserRepository; + + @Transactional + public void log(String action, String details, AppUser performedBy) { + log(action, details, performedBy, null, null); + } + + public void log(String action, String details, AppUser performedBy, String targetType, String targetId) { + try { + AuditLog log = new AuditLog(); + log.setUserName(performedBy.getUsername()); + log.setAction(action); + log.setTargetType(targetType); + log.setTargetId(targetId); + log.setDetails(details); + log.setCreatedAt(Instant.now()); + log.setPerformedBy(performedBy); + + auditLogRepository.save(log); + } catch (Exception e) { + log.warn("Failed to write audit log: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/org/example/team6backend/document/controller/DocumentController.java b/src/main/java/org/example/team6backend/document/controller/DocumentController.java index 31a85b4..033cbcc 100644 --- a/src/main/java/org/example/team6backend/document/controller/DocumentController.java +++ b/src/main/java/org/example/team6backend/document/controller/DocumentController.java @@ -50,7 +50,7 @@ public ResponseEntity getFile(@PathVariable String fileKey, throw new ResponseStatusException(HttpStatus.NOT_FOUND); } - InputStream inputStream = documentService.downloadFile(fileKey); + InputStream inputStream = documentService.downloadFile(fileKey, user); MediaType mediaType = document.getContentType() != null ? MediaType.parseMediaType(document.getContentType()) : MediaType.APPLICATION_OCTET_STREAM; @@ -72,7 +72,7 @@ public ResponseEntity> uploadFile(@PathVariable Long incidentI for (MultipartFile file : files) { if (!file.isEmpty()) { - Document doc = documentService.uploadFile(file, incident); + Document doc = documentService.uploadFile(file, incident, user); DocumentDTO dto = new DocumentDTO(); dto.setFileName(doc.getFileName()); dto.setFileKey(doc.getFileKey()); @@ -93,10 +93,12 @@ public ResponseEntity deleteFile(@PathVariable Long documentId, @AuthenticationPrincipal CustomUserDetails userDetails) { log.info("DELETE /documents/{} - Deleting file", documentId); + AppUser user = userDetails.getUser(); + Document document = documentService.getById(documentId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); - documentService.deleteFile(document); + documentService.deleteFile(document, user); return ResponseEntity.noContent().build(); } } diff --git a/src/main/java/org/example/team6backend/document/service/DocumentService.java b/src/main/java/org/example/team6backend/document/service/DocumentService.java index 9d5877b..00f80f3 100644 --- a/src/main/java/org/example/team6backend/document/service/DocumentService.java +++ b/src/main/java/org/example/team6backend/document/service/DocumentService.java @@ -2,9 +2,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.example.team6backend.auditlog.service.AuditLogService; import org.example.team6backend.document.entity.Document; import org.example.team6backend.document.repository.DocumentRepository; import org.example.team6backend.incident.entity.Incident; +import org.example.team6backend.user.entity.AppUser; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,10 +25,11 @@ public class DocumentService { private final MinioService minioService; private final DocumentRepository documentRepository; + private final AuditLogService auditLogService; /** Upload file */ @Transactional - public Document uploadFile(MultipartFile file, Incident incident) { + public Document uploadFile(MultipartFile file, Incident incident, AppUser user) { String fileKey = UUID.randomUUID() + "_" + file.getOriginalFilename(); boolean uploaded = false; @@ -41,7 +44,12 @@ public Document uploadFile(MultipartFile file, Incident incident) { document.setFileSize(file.getSize()); document.setIncident(incident); - return documentRepository.save(document); + Document savedDocument = documentRepository.save(document); + + auditLogService.log("UPLOAD_DOCUMENT", user.getName() + " uploaded '" + file.getOriginalFilename() + "'", + user, "Document", savedDocument.getId().toString()); + + return savedDocument; } catch (Exception e) { if (uploaded) { @@ -57,9 +65,14 @@ public Document uploadFile(MultipartFile file, Incident incident) { /** Download file */ @Transactional - public InputStream downloadFile(String objectKey) { + public InputStream downloadFile(String objectKey, AppUser user) { try { - return minioService.downloadFile(objectKey); + InputStream stream = minioService.downloadFile(objectKey); + + auditLogService.log("DOWNLOAD_DOCUMENT", user.getName() + " downloaded file '" + objectKey + "'", user); + + return stream; + } catch (MinioService.FileMissingException e) { log.warn("Missing file in Minio: {}", objectKey, e); @@ -82,14 +95,20 @@ public InputStream downloadFile(String objectKey) { /** Delete file */ @Transactional - public void deleteFile(Document document) { + public void deleteFile(Document document, AppUser user) { + String fileName = document.getFileName(); + String fileKey = document.getFileKey(); + documentRepository.delete(document); + auditLogService.log("DELETE_DOCUMENT", user.getName() + " deleted file '" + fileName + "'", user, "Document", + document.getId().toString()); try { - minioService.deleteFile(document.getFileKey()); + minioService.deleteFile(fileKey); } catch (Exception e) { - log.warn("Could not delete file: {}", document.getFileKey(), e); + log.warn("Could not delete file: {}", fileKey, e); } + } /** Fetch all files connected to one incident */ diff --git a/src/main/java/org/example/team6backend/incident/service/IncidentService.java b/src/main/java/org/example/team6backend/incident/service/IncidentService.java index 97bad43..cb53d6f 100644 --- a/src/main/java/org/example/team6backend/incident/service/IncidentService.java +++ b/src/main/java/org/example/team6backend/incident/service/IncidentService.java @@ -2,6 +2,7 @@ import lombok.extern.slf4j.Slf4j; import org.example.team6backend.activity.service.ActivityLogService; +import org.example.team6backend.auditlog.service.AuditLogService; import org.example.team6backend.document.entity.Document; import org.example.team6backend.document.service.DocumentService; import org.example.team6backend.document.service.MinioService; @@ -38,16 +39,18 @@ public class IncidentService { private final AppUserRepository userRepository; private final NotificationService notificationService; private final MinioService minioService; + private final AuditLogService auditLogService; public IncidentService(IncidentRepository incidentRepository, ActivityLogService activityLogService, DocumentService documentService, AppUserRepository userRepository, NotificationService notificationService, - MinioService minioService) { + MinioService minioService, AuditLogService auditLogService) { this.incidentRepository = incidentRepository; this.activityLogService = activityLogService; this.documentService = documentService; this.userRepository = userRepository; this.notificationService = notificationService; this.minioService = minioService; + this.auditLogService = auditLogService; } /** @@ -83,13 +86,16 @@ public Incident createIncident(IncidentRequest incidentRequest, List new ResourceNotFoundException("Incident not found!")); + String incidentSubject = incident.getSubject(); + for (Document document : incident.getDocuments()) { try { minioService.deleteFile(document.getFileKey()); @@ -160,6 +170,9 @@ public void deleteIncident(Long incidentId) { } } incidentRepository.delete(incident); + + auditLogService.log("DELETE_INCIDENT", currentUser.getName() + " deleted incident #" + incidentId, currentUser, + "Incident", incidentId.toString()); } @Transactional @@ -193,6 +206,10 @@ public Incident assignIncidentToHandler(Long incidentId, String handlerId, AppUs currentUser.getName() + " assigned incident from " + oldHandlerName + " to " + handler.getName(), savedIncident, currentUser); + auditLogService.log("ASSIGN_INCIDENT", + currentUser.getName() + " assigned incident #" + incidentId + " to " + handler.getName(), currentUser, + "Incident", incidentId.toString()); + notificationService.createNotification("You have been assigned to an incident", handler, savedIncident); if (!handler.getId().equals(savedIncident.getCreatedBy().getId())) { @@ -224,6 +241,9 @@ public Incident updateIncidentStatus(Long incidentId, IncidentStatus newStatus, currentUser.getName() + " changed status from " + oldStatus + " to " + newStatus, savedIncident, currentUser); + auditLogService.log("UPDATE_STATUS", currentUser.getName() + " changed incident #" + incidentId + + " status from " + oldStatus + " to " + newStatus, currentUser); + if (savedIncident.getCreatedBy() != null && !savedIncident.getCreatedBy().getId().equals(currentUser.getId())) { notificationService.createNotification( @@ -265,6 +285,10 @@ public Incident unassignIncident(Long incidentId, AppUser currentUser) { currentUser.getName() + " unassigned incident from " + previousHandler.getName(), savedIncident, currentUser); + auditLogService.log("UNASSIGN_INCIDENT", + currentUser.getName() + " unassigned incident #" + incidentId + " from " + previousHandler.getName(), + currentUser); + notificationService.createNotification( "Incident #" + incident.getId() + " has been unassigned from you by " + currentUser.getName(), previousHandler, savedIncident); @@ -301,6 +325,8 @@ public Incident closeIncident(Long incidentId, AppUser currentUser) { currentUser.getName() + " closed incident (status changed from " + oldStatus + " to CLOSED)", savedIncident, currentUser); + auditLogService.log("CLOSE_INCIDENT", currentUser.getName() + " closed incident #" + incidentId, currentUser); + notificationService.createNotification( "Incident #" + incident.getId() + " has been closed by " + currentUser.getName(), savedIncident.getCreatedBy(), savedIncident); @@ -341,6 +367,9 @@ public Incident resolveIncident(Long incidentId, AppUser currentUser) { currentUser.getName() + " resolved incident (status changed from " + oldStatus + " to RESOLVED)", savedIncident, currentUser); + auditLogService.log("RESOLVE_INCIDENT", currentUser.getName() + " resolved incident #" + incidentId, + currentUser); + notificationService.createNotification( "Incident #" + incident.getId() + " has been marked as resolved by " + currentUser.getName(), savedIncident.getCreatedBy(), savedIncident); diff --git a/src/main/java/org/example/team6backend/user/service/UserService.java b/src/main/java/org/example/team6backend/user/service/UserService.java index 47905b2..27d18ac 100644 --- a/src/main/java/org/example/team6backend/user/service/UserService.java +++ b/src/main/java/org/example/team6backend/user/service/UserService.java @@ -3,6 +3,7 @@ import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.example.team6backend.auditlog.service.AuditLogService; import org.example.team6backend.user.entity.AppUser; import org.example.team6backend.user.entity.UserRole; import org.example.team6backend.exception.UserNotFoundException; @@ -23,6 +24,7 @@ public class UserService { private final AppUserRepository userRepository; private final EntityManager entityManager; + private final AuditLogService auditLogService; @Transactional public AppUser createOrUpdateUser(Map attributes) { @@ -115,7 +117,7 @@ public Page searchUsers(String search, Pageable pageable) { } @Transactional - public AppUser updateUserRole(String userId, UserRole newRole) { + public AppUser updateUserRole(String userId, UserRole newRole, AppUser currentAdmin) { log.info("Attempting to update role for user: {} to {}", userId, newRole); AppUser user = getUserById(userId); @@ -126,12 +128,16 @@ public AppUser updateUserRole(String userId, UserRole newRole) { user.setRole(newRole); AppUser savedUser = userRepository.save(user); + auditLogService.log("UPDATE_USER_ROLE", + currentAdmin.getName() + " changed " + user.getName() + "'s role from " + oldRole + " to " + newRole, + currentAdmin, "User", userId); + log.info("Role updated for userId={} from {} to {}", userId, oldRole, newRole); return savedUser; } @Transactional - public AppUser updateUserActiveStatus(String userId, boolean active) { + public AppUser updateUserActiveStatus(String userId, boolean active, AppUser currentAdmin) { log.info("Attempting to update active status for user: {} to active={}", userId, active); AppUser user = getUserById(userId); @@ -152,12 +158,15 @@ public AppUser updateUserActiveStatus(String userId, boolean active) { user.setActive(active); AppUser savedUser = userRepository.save(user); + auditLogService.log("UPDATE_USER_STATUS", + currentAdmin.getName() + (active ? " activated " : " deactivated ") + user.getName(), currentAdmin); + log.info("Active status updated for userId={} from {} to {}", userId, oldStatus, active); return savedUser; } @Transactional - public AppUser approvePendingUser(String userId) { + public AppUser approvePendingUser(String userId, AppUser currentAdmin) { log.info("Attempting to approve pending user: {}", userId); AppUser user = getUserById(userId); @@ -173,13 +182,17 @@ public AppUser approvePendingUser(String userId) { user.setActive(true); AppUser savedUser = userRepository.save(user); + + auditLogService.log("APPROVE_USER", currentAdmin.getName() + " approved " + user.getName() + " as RESIDENT", + currentAdmin); + log.info("User approved successfully: userId={}, githubLogin={}, new role={}", userId, user.getGithubLogin(), savedUser.getRole()); return savedUser; } @Transactional - public void deleteUser(String userId) { + public void deleteUser(String userId, AppUser currentAdmin) { log.info("🗑️ Starting deletion process for user: {}", userId); AppUser user = getUserById(userId); @@ -215,6 +228,10 @@ public void deleteUser(String userId) { "notifications"); userRepository.delete(user); + + auditLogService.log("DELETE_USER", currentAdmin.getName() + " deleted user '" + user.getName() + "'", + currentAdmin, "User", userId); + log.info("User DELETED successfully: userId={}, githubLogin={}, role={}", userId, user.getGithubLogin(), user.getRole()); } diff --git a/src/main/resources/db/migration/V11__create_audit_log_table.sql b/src/main/resources/db/migration/V11__create_audit_log_table.sql new file mode 100644 index 0000000..b1cb9f4 --- /dev/null +++ b/src/main/resources/db/migration/V11__create_audit_log_table.sql @@ -0,0 +1,15 @@ +CREATE TABLE audit_log ( + id BIGSERIAL PRIMARY KEY, + user_name VARCHAR(255), + action VARCHAR(255), + target_type VARCHAR(255), + target_id VARCHAR(255), + details TEXT, + created_at TIMESTAMP, + user_id VARCHAR(255), + CONSTRAINT fk_audit_log_user FOREIGN KEY (user_id) + REFERENCES app_user(id) ON DELETE SET NULL); +CREATE INDEX idx_audit_log_created_at ON audit_log (created_at DESC); +CREATE INDEX idx_audit_log_user_id ON audit_log (user_id); +CREATE INDEX idx_audit_log_action ON audit_log (action); +); \ No newline at end of file diff --git a/src/test/java/org/example/team6backend/admin/AdminControllerTest.java b/src/test/java/org/example/team6backend/admin/AdminControllerTest.java index c9e7a98..6539ed7 100644 --- a/src/test/java/org/example/team6backend/admin/AdminControllerTest.java +++ b/src/test/java/org/example/team6backend/admin/AdminControllerTest.java @@ -1,5 +1,7 @@ package org.example.team6backend.admin; +import org.example.team6backend.auditlog.service.AuditLogService; +import org.example.team6backend.security.CustomUserDetails; import org.example.team6backend.user.entity.AppUser; import org.example.team6backend.user.entity.UserRole; import org.example.team6backend.user.mapper.UserMapper; @@ -17,17 +19,19 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; - import java.time.Instant; import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.UUID; - import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -46,9 +50,14 @@ class AdminControllerTest { @MockitoBean private UserMapper userMapper; + @MockitoBean + private AuditLogService auditLogService; + private AppUser testUser; private AppUser pendingUser; private AppUser handlerUser; + private AppUser adminUser; + private UsernamePasswordAuthenticationToken auth; @BeforeEach void setUp() { @@ -84,6 +93,21 @@ void setUp() { handlerUser.setActive(true); handlerUser.setCreatedAt(Instant.now()); handlerUser.setUpdatedAt(Instant.now()); + + adminUser = new AppUser(); + adminUser.setId(UUID.randomUUID().toString()); + adminUser.setGithubId("admin123"); + adminUser.setGithubLogin("admin"); + adminUser.setName("Admin User"); + adminUser.setEmail("admin@test.com"); + adminUser.setRole(UserRole.ADMIN); + adminUser.setActive(true); + adminUser.setCreatedAt(Instant.now()); + adminUser.setUpdatedAt(Instant.now()); + + CustomUserDetails principal = new CustomUserDetails(adminUser, Map.of()); + auth = new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(auth); } @Test @@ -96,7 +120,7 @@ void shouldGetAllUsers() throws Exception { when(userService.getAllUsersPaginated(any(Pageable.class))).thenReturn(userPage); when(userMapper.toResponsePage(any(Page.class))).thenReturn(new PageImpl<>(Arrays.asList())); - mockMvc.perform(get("/api/admin/users")).andExpect(status().isOk()) + mockMvc.perform(get("/api/admin/users").with(authentication(auth))).andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)); } @@ -111,7 +135,8 @@ void shouldGetUsersWithSearch() throws Exception { when(userService.searchUsers(eq(searchTerm), any(Pageable.class))).thenReturn(userPage); when(userMapper.toResponsePage(any(Page.class))).thenReturn(new PageImpl<>(Arrays.asList())); - mockMvc.perform(get("/api/admin/users").param("search", searchTerm)).andExpect(status().isOk()); + mockMvc.perform(get("/api/admin/users").param("search", searchTerm).with(authentication(auth))) + .andExpect(status().isOk()); } @Test @@ -124,7 +149,7 @@ void shouldGetPendingUsers() throws Exception { when(userService.getUsersByRolePaginated(eq(UserRole.PENDING), any(Pageable.class))).thenReturn(pendingPage); when(userMapper.toResponsePage(any(Page.class))).thenReturn(new PageImpl<>(Arrays.asList())); - mockMvc.perform(get("/api/admin/users/pending")).andExpect(status().isOk()); + mockMvc.perform(get("/api/admin/users/pending").with(authentication(auth))).andExpect(status().isOk()); } @Test @@ -135,7 +160,7 @@ void shouldGetUserById() throws Exception { when(userService.getUserById(userId)).thenReturn(testUser); when(userMapper.toResponse(any(AppUser.class))).thenReturn(null); - mockMvc.perform(get("/api/admin/users/{userId}", userId)).andExpect(status().isOk()); + mockMvc.perform(get("/api/admin/users/{userId}", userId).with(authentication(auth))).andExpect(status().isOk()); } @Test @@ -145,10 +170,11 @@ void shouldApprovePendingUser() throws Exception { AppUser approvedUser = pendingUser; approvedUser.setRole(UserRole.RESIDENT); - when(userService.approvePendingUser(userId)).thenReturn(approvedUser); + when(userService.approvePendingUser(eq(userId), any(AppUser.class))).thenReturn(approvedUser); when(userMapper.toResponse(any(AppUser.class))).thenReturn(null); - mockMvc.perform(post("/api/admin/users/{userId}/approve", userId)).andExpect(status().isOk()); + mockMvc.perform(post("/api/admin/users/{userId}/approve", userId).with(authentication(auth))) + .andExpect(status().isOk()); } @Test @@ -161,11 +187,11 @@ void shouldUpdateUserRole() throws Exception { when(userService.getUserById(userId)).thenReturn(testUser); when(userService.getAllUsers()).thenReturn(Arrays.asList(testUser, updatedUser)); - when(userService.updateUserRole(userId, UserRole.ADMIN)).thenReturn(updatedUser); + when(userService.updateUserRole(eq(userId), eq(UserRole.ADMIN), any(AppUser.class))).thenReturn(updatedUser); when(userMapper.toResponse(any(AppUser.class))).thenReturn(null); mockMvc.perform(patch("/api/admin/users/{userId}/role", userId).contentType(MediaType.APPLICATION_JSON) - .content(requestBody)).andExpect(status().isOk()); + .content(requestBody).with(authentication(auth))).andExpect(status().isOk()); } @Test @@ -176,11 +202,11 @@ void shouldUpdateUserStatus() throws Exception { AppUser updatedUser = testUser; updatedUser.setActive(false); - when(userService.updateUserActiveStatus(userId, false)).thenReturn(updatedUser); + when(userService.updateUserActiveStatus(eq(userId), eq(false), any(AppUser.class))).thenReturn(updatedUser); when(userMapper.toResponse(any(AppUser.class))).thenReturn(null); mockMvc.perform(patch("/api/admin/users/{userId}/status", userId).contentType(MediaType.APPLICATION_JSON) - .content(requestBody)).andExpect(status().isOk()); + .content(requestBody).with(authentication(auth))).andExpect(status().isOk()); } @Test @@ -189,11 +215,12 @@ void shouldDeleteUser() throws Exception { String userId = testUser.getId(); when(userService.getUserById(userId)).thenReturn(testUser); - doNothing().when(userService).deleteUser(userId); + doNothing().when(userService).deleteUser(eq(userId), any(AppUser.class)); - mockMvc.perform(delete("/api/admin/users/{userId}", userId)).andExpect(status().isNoContent()); + mockMvc.perform(delete("/api/admin/users/{userId}", userId).with(authentication(auth))) + .andExpect(status().isNoContent()); - verify(userService).deleteUser(userId); + verify(userService).deleteUser(eq(userId), any(AppUser.class)); } @Test @@ -211,8 +238,9 @@ void shouldGetStats() throws Exception { when(userService.getUsersByRole(UserRole.HANDLER)).thenReturn(handlers); when(userService.getUsersByRole(UserRole.ADMIN)).thenReturn(admins); - mockMvc.perform(get("/api/admin/stats")).andExpect(status().isOk()).andExpect(jsonPath("$.totalUsers").value(3)) - .andExpect(jsonPath("$.pendingUsers").value(1)).andExpect(jsonPath("$.residents").value(1)) - .andExpect(jsonPath("$.handlers").value(1)).andExpect(jsonPath("$.admins").value(0)); + mockMvc.perform(get("/api/admin/stats").with(authentication(auth))).andExpect(status().isOk()) + .andExpect(jsonPath("$.totalUsers").value(3)).andExpect(jsonPath("$.pendingUsers").value(1)) + .andExpect(jsonPath("$.residents").value(1)).andExpect(jsonPath("$.handlers").value(1)) + .andExpect(jsonPath("$.admins").value(0)); } } diff --git a/src/test/java/org/example/team6backend/document/controller/DocumentControllerTest.java b/src/test/java/org/example/team6backend/document/controller/DocumentControllerTest.java index dc8cd87..c4959d8 100644 --- a/src/test/java/org/example/team6backend/document/controller/DocumentControllerTest.java +++ b/src/test/java/org/example/team6backend/document/controller/DocumentControllerTest.java @@ -1,4 +1,5 @@ package org.example.team6backend.document.controller; +import org.example.team6backend.auditlog.service.AuditLogService; import org.example.team6backend.document.entity.Document; import org.example.team6backend.document.service.DocumentService; import org.example.team6backend.document.service.MinioService; @@ -6,6 +7,7 @@ import org.example.team6backend.incident.service.IncidentService; import org.example.team6backend.security.CustomOAuth2UserService; import org.example.team6backend.security.CustomUserDetails; +import org.example.team6backend.user.entity.UserRole; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.ImportAutoConfiguration; @@ -16,6 +18,7 @@ import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; + import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import java.io.ByteArrayInputStream; import java.util.Map; @@ -49,6 +52,9 @@ public class DocumentControllerTest { @MockitoBean private CustomOAuth2UserService customOAuth2UserService; + @MockitoBean + private AuditLogService auditLogService; + @Test void getFile_shouldReturnDocument() throws Exception { Incident incident = new Incident(); @@ -70,7 +76,7 @@ void getFile_shouldReturnDocument() throws Exception { when(documentService.getByFileKey("abc")).thenReturn(Optional.of(document)); when(incidentService.getById(eq(1L), any())).thenReturn(incident); - when(documentService.downloadFile("abc")).thenReturn(new ByteArrayInputStream("hello".getBytes())); + when(documentService.downloadFile(eq("abc"), any())).thenReturn(new ByteArrayInputStream("hello".getBytes())); mockMvc.perform(get("/documents/abc").with(authentication(auth))).andExpect(status().isOk()); } @@ -113,9 +119,20 @@ void uploadFile_shouldReturnCreated() throws Exception { MockMultipartFile mockFile = new MockMultipartFile("files", "test.pdf", "application/pdf", "hello".getBytes()); - when(incidentService.getById(eq(1L), any())).thenReturn(incident); - when(documentService.uploadFile(any(), eq(incident))).thenReturn(document); + AppUser appUser = new AppUser(); + appUser.setId("user-1"); + appUser.setName("Test User"); + appUser.setEmail("test@test.com"); + appUser.setRole(UserRole.RESIDENT); + + CustomUserDetails principal = new CustomUserDetails(appUser, Map.of()); + UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(principal, "password", + principal.getAuthorities()); + + when(incidentService.getById(anyLong(), any())).thenReturn(incident); + when(documentService.uploadFile(any(), any(), any())).thenReturn(document); - mockMvc.perform(multipart("/documents/upload/1").file(mockFile)).andExpect(status().isCreated()); + mockMvc.perform(multipart("/documents/upload/1").file(mockFile).with(authentication(auth))) + .andExpect(status().isCreated()); } } diff --git a/src/test/java/org/example/team6backend/document/service/DocumentServiceTest.java b/src/test/java/org/example/team6backend/document/service/DocumentServiceTest.java index de27bca..2b27103 100644 --- a/src/test/java/org/example/team6backend/document/service/DocumentServiceTest.java +++ b/src/test/java/org/example/team6backend/document/service/DocumentServiceTest.java @@ -1,8 +1,10 @@ package org.example.team6backend.document.service; - import org.example.team6backend.document.entity.Document; import org.example.team6backend.document.repository.DocumentRepository; import org.example.team6backend.incident.entity.Incident; +import org.example.team6backend.user.entity.AppUser; +import org.example.team6backend.user.entity.UserRole; +import org.example.team6backend.auditlog.service.AuditLogService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -10,6 +12,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.web.multipart.MultipartFile; +import java.time.Instant; import java.util.Optional; import static org.mockito.ArgumentMatchers.eq; import static org.junit.jupiter.api.Assertions.*; @@ -23,17 +26,37 @@ class DocumentServiceTest { @Mock private MinioService minioService; - @InjectMocks - private DocumentService documentService; + @Mock private DocumentRepository documentRepository; + @Mock private MultipartFile file; + + @Mock + private AuditLogService auditLogService; + + @InjectMocks + private DocumentService documentService; + + private AppUser testUser; private Incident incident; @BeforeEach void setUp() { incident = new Incident(); + incident.setId(1L); + + testUser = new AppUser(); + testUser.setId("user-123"); + testUser.setGithubId("test123"); + testUser.setGithubLogin("testuser"); + testUser.setName("Test User"); + testUser.setEmail("test@test.com"); + testUser.setRole(UserRole.RESIDENT); + testUser.setActive(true); + testUser.setCreatedAt(Instant.now()); + testUser.setUpdatedAt(Instant.now()); } @Test @@ -43,11 +66,12 @@ void uploadFile_shouldSaveDocument_whenSuccessful() { when(file.getSize()).thenReturn(100L); Document document = new Document(); + document.setId(1L); document.setFileName("test.pdf"); when(documentRepository.save(any(Document.class))).thenReturn(document); - - Document result = documentService.uploadFile(file, incident); + doNothing().when(auditLogService).log(anyString(), anyString(), any(AppUser.class), anyString(), anyString()); + Document result = documentService.uploadFile(file, incident, testUser); assertEquals("test.pdf", result.getFileName()); verify(minioService).uploadFile(anyString(), eq(file)); @@ -63,18 +87,23 @@ void uploadFile_shouldCleanUpAndThrow_ifSaveFails() { doNothing().when(minioService).uploadFile(anyString(), eq(file)); when(documentRepository.save(any())).thenThrow(new RuntimeException()); - assertThrows(RuntimeException.class, () -> documentService.uploadFile(file, incident)); + assertThrows(RuntimeException.class, () -> documentService.uploadFile(file, incident, testUser)); ArgumentCaptor uploadedKey = ArgumentCaptor.forClass(String.class); verify(minioService).uploadFile(uploadedKey.capture(), eq(file)); verify(minioService).deleteFile(uploadedKey.getValue()); + + verify(auditLogService, never()).log(anyString(), anyString(), any()); } @Test void deleteFile_shouldDeleteRepoAndMinio() { Document document = new Document(); + document.setId(1L); document.setFileKey("abc"); - documentService.deleteFile(document); + doNothing().when(auditLogService).log(anyString(), anyString(), any(AppUser.class), anyString(), anyString()); + + documentService.deleteFile(document, testUser); verify(documentRepository).delete(document); verify(minioService).deleteFile("abc"); @@ -83,12 +112,15 @@ void deleteFile_shouldDeleteRepoAndMinio() { @Test void deleteFile_shouldIgnoreMinioFail() { Document document = new Document(); + document.setId(1L); document.setFileKey("abc"); doThrow(new RuntimeException("MinIO Failure")).when(minioService).deleteFile(anyString()); System.out.println(minioService.getClass()); - assertDoesNotThrow(() -> documentService.deleteFile(document)); + doNothing().when(auditLogService).log(anyString(), anyString(), any(AppUser.class), anyString(), anyString()); + + assertDoesNotThrow(() -> documentService.deleteFile(document, testUser)); verify(minioService).deleteFile("abc"); verify(documentRepository).delete(document); } diff --git a/src/test/java/org/example/team6backend/incident/controller/IncidentControllerTest.java b/src/test/java/org/example/team6backend/incident/controller/IncidentControllerTest.java index 45017bc..86c924a 100644 --- a/src/test/java/org/example/team6backend/incident/controller/IncidentControllerTest.java +++ b/src/test/java/org/example/team6backend/incident/controller/IncidentControllerTest.java @@ -1,4 +1,5 @@ package org.example.team6backend.incident.controller; +import org.example.team6backend.auditlog.service.AuditLogService; import org.example.team6backend.incident.entity.Incident; import org.example.team6backend.incident.service.IncidentService; import org.example.team6backend.notification.service.NotificationService; @@ -53,6 +54,9 @@ class IncidentControllerTest { @MockitoBean private NotificationService notificationService; + @MockitoBean + private AuditLogService auditLogService; + @AfterEach void clearContext() { SecurityContextHolder.clearContext(); diff --git a/src/test/java/org/example/team6backend/incident/service/IncidentServiceTest.java b/src/test/java/org/example/team6backend/incident/service/IncidentServiceTest.java index 4f6780e..7e0d175 100644 --- a/src/test/java/org/example/team6backend/incident/service/IncidentServiceTest.java +++ b/src/test/java/org/example/team6backend/incident/service/IncidentServiceTest.java @@ -1,4 +1,5 @@ package org.example.team6backend.incident.service; +import org.example.team6backend.auditlog.service.AuditLogService; import org.example.team6backend.document.entity.Document; import org.example.team6backend.notification.service.NotificationService; import org.example.team6backend.activity.service.ActivityLogService; @@ -53,6 +54,9 @@ class IncidentServiceTest { @Mock private NotificationService notificationService; + @Mock + private AuditLogService auditLogService; + private Incident incident; private AppUser user; private AppUser handler; @@ -102,12 +106,12 @@ void createIncident_shouldSaveWithFiles() { document.setFileKey("abc123"); when(incidentRepository.save(any())).thenReturn(incident); - when(documentService.uploadFile(file, incident)).thenReturn(document); + when(documentService.uploadFile(eq(file), eq(incident), eq(user))).thenReturn(document); Incident result = incidentService.createIncident(new IncidentRequest(), List.of(file), user); assertNotNull(result); - verify(documentService).uploadFile(file, incident); + verify(documentService).uploadFile(eq(file), eq(incident), eq(user)); } @Test @@ -201,7 +205,7 @@ void deleteIncident() { incident.setDocuments(List.of()); - incidentService.deleteIncident(1L); + incidentService.deleteIncident(1L, user); verify(incidentRepository).delete(incident); } } diff --git a/src/test/java/org/example/team6backend/user/service/UserServiceTest.java b/src/test/java/org/example/team6backend/user/service/UserServiceTest.java index dfca244..b656eab 100644 --- a/src/test/java/org/example/team6backend/user/service/UserServiceTest.java +++ b/src/test/java/org/example/team6backend/user/service/UserServiceTest.java @@ -2,6 +2,7 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.Query; +import org.example.team6backend.auditlog.service.AuditLogService; import org.example.team6backend.user.entity.AppUser; import org.example.team6backend.user.entity.UserRole; import org.example.team6backend.exception.UserNotFoundException; @@ -37,6 +38,9 @@ class UserServiceTest { @Mock private Query mockQuery; + @Mock + private AuditLogService auditLogService; + @InjectMocks private UserService userService; @@ -355,10 +359,11 @@ void updateUserRole_ShouldUpdateRole() { when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); when(userRepository.save(any(AppUser.class))).thenAnswer(invocation -> invocation.getArgument(0)); - AppUser updated = userService.updateUserRole(testUserId, newRole); + AppUser updated = userService.updateUserRole(testUserId, newRole, adminUser); assertThat(updated.getRole()).isEqualTo(newRole); verify(userRepository).save(testUser); + verify(auditLogService).log(anyString(), anyString(), eq(adminUser), anyString(), anyString()); } @Test @@ -368,7 +373,7 @@ void updateUserRole_ShouldUpdateFromHandlerToAdmin() { when(userRepository.findById(handlerUserId)).thenReturn(Optional.of(handlerUser)); when(userRepository.save(any(AppUser.class))).thenAnswer(invocation -> invocation.getArgument(0)); - AppUser updated = userService.updateUserRole(handlerUserId, newRole); + AppUser updated = userService.updateUserRole(handlerUserId, newRole, adminUser); assertThat(updated.getRole()).isEqualTo(UserRole.ADMIN); verify(userRepository).save(handlerUser); @@ -381,7 +386,7 @@ void updateUserRole_ShouldUpdateFromAdminToHandler() { when(userRepository.findById(adminUserId)).thenReturn(Optional.of(adminUser)); when(userRepository.save(any(AppUser.class))).thenAnswer(invocation -> invocation.getArgument(0)); - AppUser updated = userService.updateUserRole(adminUserId, newRole); + AppUser updated = userService.updateUserRole(adminUserId, newRole, adminUser); assertThat(updated.getRole()).isEqualTo(UserRole.HANDLER); verify(userRepository).save(adminUser); @@ -394,7 +399,7 @@ void updateUserActiveStatus_ShouldDeactivateUser() { when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); when(userRepository.save(any(AppUser.class))).thenAnswer(invocation -> invocation.getArgument(0)); - AppUser updated = userService.updateUserActiveStatus(testUserId, newStatus); + AppUser updated = userService.updateUserActiveStatus(testUserId, newStatus, adminUser); assertThat(updated.isActive()).isFalse(); verify(userRepository).save(testUser); @@ -406,7 +411,7 @@ void updateUserActiveStatus_ShouldActivateUser() { when(userRepository.findById(inactiveUserId)).thenReturn(Optional.of(inactiveUser)); when(userRepository.save(any(AppUser.class))).thenAnswer(invocation -> invocation.getArgument(0)); - AppUser updated = userService.updateUserActiveStatus(inactiveUserId, true); + AppUser updated = userService.updateUserActiveStatus(inactiveUserId, true, adminUser); assertThat(updated.isActive()).isTrue(); verify(userRepository).save(inactiveUser); @@ -418,7 +423,7 @@ void updateUserActiveStatus_ShouldThrowException_WhenDeactivatingLastAdmin() { when(userRepository.findById(adminUserId)).thenReturn(Optional.of(adminUser)); when(userRepository.countByRoleAndActiveTrue(UserRole.ADMIN)).thenReturn(1L); - assertThatThrownBy(() -> userService.updateUserActiveStatus(adminUserId, false)) + assertThatThrownBy(() -> userService.updateUserActiveStatus(adminUserId, false, adminUser)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("Cannot deactivate the last active admin user"); verify(userRepository, never()).save(any()); @@ -432,7 +437,7 @@ void updateUserActiveStatus_ShouldAllowDeactivatingAdmin_WhenMultipleAdminsExist when(userRepository.countByRoleAndActiveTrue(UserRole.ADMIN)).thenReturn(2L); when(userRepository.save(any(AppUser.class))).thenAnswer(invocation -> invocation.getArgument(0)); - AppUser updated = userService.updateUserActiveStatus(adminUserId, newStatus); + AppUser updated = userService.updateUserActiveStatus(adminUserId, newStatus, adminUser); assertThat(updated.isActive()).isFalse(); verify(userRepository).save(adminUser); @@ -444,7 +449,7 @@ void approvePendingUser_ShouldApproveAndChangeRoleToResident() { when(userRepository.findById(pendingUserId)).thenReturn(Optional.of(pendingUser)); when(userRepository.save(any(AppUser.class))).thenAnswer(invocation -> invocation.getArgument(0)); - AppUser approved = userService.approvePendingUser(pendingUserId); + AppUser approved = userService.approvePendingUser(pendingUserId, adminUser); assertThat(approved.getRole()).isEqualTo(UserRole.RESIDENT); assertThat(approved.isActive()).isTrue(); @@ -456,8 +461,8 @@ void approvePendingUser_ShouldThrowException_WhenUserIsNotPending() { when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); - assertThatThrownBy(() -> userService.approvePendingUser(testUserId)).isInstanceOf(IllegalStateException.class) - .hasMessageContaining("User is not pending approval"); + assertThatThrownBy(() -> userService.approvePendingUser(testUserId, adminUser)) + .isInstanceOf(IllegalStateException.class).hasMessageContaining("User is not pending approval"); verify(userRepository, never()).save(any()); } @@ -466,8 +471,8 @@ void approvePendingUser_ShouldThrowException_WhenUserIsAdmin() { when(userRepository.findById(adminUserId)).thenReturn(Optional.of(adminUser)); - assertThatThrownBy(() -> userService.approvePendingUser(adminUserId)).isInstanceOf(IllegalStateException.class) - .hasMessageContaining("User is not pending approval"); + assertThatThrownBy(() -> userService.approvePendingUser(adminUserId, adminUser)) + .isInstanceOf(IllegalStateException.class).hasMessageContaining("User is not pending approval"); verify(userRepository, never()).save(any()); } @@ -476,7 +481,8 @@ void approvePendingUser_ShouldThrowException_WhenUserIsResident() { when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); - assertThatThrownBy(() -> userService.approvePendingUser(testUserId)).isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> userService.approvePendingUser(testUserId, adminUser)) + .isInstanceOf(IllegalStateException.class); verify(userRepository, never()).save(any()); } @@ -490,7 +496,7 @@ void deleteUser_ShouldDeleteUserAndCleanupRelations() { lenient().when(mockQuery.executeUpdate()).thenReturn(1); lenient().doNothing().when(userRepository).delete(testUser); - userService.deleteUser(testUserId); + userService.deleteUser(testUserId, adminUser); verify(userRepository).delete(testUser); verify(entityManager, atLeast(6)).createNativeQuery(anyString()); @@ -502,8 +508,8 @@ void deleteUser_ShouldThrowException_WhenDeletingLastAdmin() { when(userRepository.findById(adminUserId)).thenReturn(Optional.of(adminUser)); when(userRepository.countByRole(UserRole.ADMIN)).thenReturn(1L); - assertThatThrownBy(() -> userService.deleteUser(adminUserId)).isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Cannot delete the last admin user"); + assertThatThrownBy(() -> userService.deleteUser(adminUserId, adminUser)) + .isInstanceOf(IllegalStateException.class).hasMessageContaining("Cannot delete the last admin user"); verify(userRepository, never()).delete(any()); } @@ -517,7 +523,7 @@ void deleteUser_ShouldAllowDeletingAdmin_WhenMultipleAdminsExist() { when(mockQuery.executeUpdate()).thenReturn(1); doNothing().when(userRepository).delete(adminUser); - userService.deleteUser(adminUserId); + userService.deleteUser(adminUserId, adminUser); verify(userRepository).delete(adminUser); verify(entityManager, atLeast(6)).createNativeQuery(anyString());