From 127bb58aa86efac8cd47c7a686339e19d09a4a48 Mon Sep 17 00:00:00 2001 From: Martin Karlsson Date: Mon, 27 Apr 2026 15:01:49 +0200 Subject: [PATCH 1/5] Feature: - Implemented findFiltered service methods CommentLogService and FileLogService - Implemented sysadmin accessible end points /log/comment and /log/file - Updated header with links to new endpoints - Created matching html pages for File Log and Comment Log - Created tests for service methods and controller --- .../audit/controller/LogViewController.java | 43 ++- .../repository/CommentLogRepository.java | 3 +- .../audit/repository/FileLogRepository.java | 3 +- .../audit/service/CommentLogService.java | 30 ++ .../audit/service/FileLogService.java | 30 ++ .../audit/service/VisaLogService.java | 1 + .../resources/templates/fragments/header.html | 2 + src/main/resources/templates/log/comment.html | 118 ++++++++ src/main/resources/templates/log/file.html | 118 ++++++++ .../controller/LogViewControllerTest.java | 164 +++++++++++ .../audit/service/CommentLogServiceTest.java | 259 ++++++++++++++++++ .../audit/service/FileLogServiceTest.java | 259 ++++++++++++++++++ 12 files changed, 1027 insertions(+), 3 deletions(-) create mode 100644 src/main/resources/templates/log/comment.html create mode 100644 src/main/resources/templates/log/file.html diff --git a/src/main/java/org/example/visacasemanagementsystem/audit/controller/LogViewController.java b/src/main/java/org/example/visacasemanagementsystem/audit/controller/LogViewController.java index c6fad38..1df8745 100644 --- a/src/main/java/org/example/visacasemanagementsystem/audit/controller/LogViewController.java +++ b/src/main/java/org/example/visacasemanagementsystem/audit/controller/LogViewController.java @@ -1,7 +1,11 @@ package org.example.visacasemanagementsystem.audit.controller; +import org.example.visacasemanagementsystem.audit.CommentEventType; +import org.example.visacasemanagementsystem.audit.FileEventType; import org.example.visacasemanagementsystem.audit.UserEventType; import org.example.visacasemanagementsystem.audit.VisaEventType; +import org.example.visacasemanagementsystem.audit.service.CommentLogService; +import org.example.visacasemanagementsystem.audit.service.FileLogService; import org.example.visacasemanagementsystem.audit.service.UserLogService; import org.example.visacasemanagementsystem.audit.service.VisaLogService; import org.springframework.data.domain.Page; @@ -34,10 +38,17 @@ public class LogViewController { private final VisaLogService visaLogService; private final UserLogService userLogService; + private final CommentLogService commentLogService; + private final FileLogService fileLogService; - public LogViewController(VisaLogService visaLogService, UserLogService userLogService) { + public LogViewController(VisaLogService visaLogService, + UserLogService userLogService, + CommentLogService commentLogService, + FileLogService fileLogService) { this.visaLogService = visaLogService; this.userLogService = userLogService; + this.commentLogService = commentLogService; + this.fileLogService = fileLogService; } @GetMapping("/visa") @@ -70,6 +81,36 @@ public String userLog( UserEventType.values(), "User Log", "log/user", model); } + @GetMapping("/comment") + public String commentLog( + @RequestParam(value = "eventType", required = false) CommentEventType eventType, + @RequestParam(value = "from", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam(value = "to", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to, + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = DEFAULT_PAGE_SIZE_STR) int size, + Model model) { + return renderLogPage(eventType, from, to, page, size, + commentLogService::findFiltered, + CommentEventType.values(), "Comment Log", "log/comment", model); + } + + @GetMapping("/file") + public String fileLog( + @RequestParam(value = "eventType", required = false) FileEventType eventType, + @RequestParam(value = "from", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam(value = "to", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to, + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = DEFAULT_PAGE_SIZE_STR) int size, + Model model) { + return renderLogPage(eventType, from, to, page, size, + fileLogService::findFiltered, + FileEventType.values(), "File Log", "log/file", model); + } + // ─── Helpers ────────────────────────────────────────────────────────── /** Four-arity fetch function: (eventType, from, to, pageable) → Page. */ diff --git a/src/main/java/org/example/visacasemanagementsystem/audit/repository/CommentLogRepository.java b/src/main/java/org/example/visacasemanagementsystem/audit/repository/CommentLogRepository.java index a8f3d33..aa250e9 100644 --- a/src/main/java/org/example/visacasemanagementsystem/audit/repository/CommentLogRepository.java +++ b/src/main/java/org/example/visacasemanagementsystem/audit/repository/CommentLogRepository.java @@ -2,8 +2,9 @@ import org.example.visacasemanagementsystem.audit.entity.CommentLog; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.stereotype.Repository; @Repository -public interface CommentLogRepository extends JpaRepository { +public interface CommentLogRepository extends JpaRepository, JpaSpecificationExecutor { } diff --git a/src/main/java/org/example/visacasemanagementsystem/audit/repository/FileLogRepository.java b/src/main/java/org/example/visacasemanagementsystem/audit/repository/FileLogRepository.java index 416ac5a..ab6292e 100644 --- a/src/main/java/org/example/visacasemanagementsystem/audit/repository/FileLogRepository.java +++ b/src/main/java/org/example/visacasemanagementsystem/audit/repository/FileLogRepository.java @@ -2,8 +2,9 @@ import org.example.visacasemanagementsystem.audit.entity.FileLog; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.stereotype.Repository; @Repository -public interface FileLogRepository extends JpaRepository { +public interface FileLogRepository extends JpaRepository, JpaSpecificationExecutor { } diff --git a/src/main/java/org/example/visacasemanagementsystem/audit/service/CommentLogService.java b/src/main/java/org/example/visacasemanagementsystem/audit/service/CommentLogService.java index 1003e75..4ed4650 100644 --- a/src/main/java/org/example/visacasemanagementsystem/audit/service/CommentLogService.java +++ b/src/main/java/org/example/visacasemanagementsystem/audit/service/CommentLogService.java @@ -3,11 +3,16 @@ import org.example.visacasemanagementsystem.audit.CommentEventType; import org.example.visacasemanagementsystem.audit.dto.CommentLogDTO; import org.example.visacasemanagementsystem.audit.entity.CommentLog; +import org.example.visacasemanagementsystem.audit.entity.CommentLog_; import org.example.visacasemanagementsystem.audit.mapper.CommentLogMapper; import org.example.visacasemanagementsystem.audit.repository.CommentLogRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; +import java.time.LocalDateTime; import java.util.List; @Service @@ -36,4 +41,29 @@ public List findAll() { .toList(); } + /** + * Used by the /log/comment page. Mirror of VisaLogService#findFiltered. + * Only non-null filters are added to the query, avoiding Hibernate 6/7's + * inability to bind null enum-typed parameters in JPQL. + */ + @PreAuthorize("isAuthenticated()") + public Page findFiltered(CommentEventType eventType, + LocalDateTime from, + LocalDateTime to, + Pageable pageable) { + // Start from the "no filter" Specification. Spring Data JPA 4 deprecated + // the where(null) idiom in favour of this explicit factory method. + Specification spec = Specification.unrestricted(); + if (eventType != null) { + spec = spec.and((root, query, cb) -> cb.equal(root.get(CommentLog_.commentEventType), eventType)); + } + if (from != null) { + spec = spec.and((root, query, cb) -> cb.greaterThanOrEqualTo(root.get(CommentLog_.timeStamp), from)); + } + if (to != null) { + spec = spec.and((root, query, cb) -> cb.lessThanOrEqualTo(root.get(CommentLog_.timeStamp), to)); + } + return commentLogRepository.findAll(spec, pageable).map(commentLogMapper::toDTO); + } + } diff --git a/src/main/java/org/example/visacasemanagementsystem/audit/service/FileLogService.java b/src/main/java/org/example/visacasemanagementsystem/audit/service/FileLogService.java index 483c980..bf36489 100644 --- a/src/main/java/org/example/visacasemanagementsystem/audit/service/FileLogService.java +++ b/src/main/java/org/example/visacasemanagementsystem/audit/service/FileLogService.java @@ -3,11 +3,16 @@ import org.example.visacasemanagementsystem.audit.FileEventType; import org.example.visacasemanagementsystem.audit.dto.FileLogDTO; import org.example.visacasemanagementsystem.audit.entity.FileLog; +import org.example.visacasemanagementsystem.audit.entity.FileLog_; import org.example.visacasemanagementsystem.audit.mapper.FileLogMapper; import org.example.visacasemanagementsystem.audit.repository.FileLogRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; +import java.time.LocalDateTime; import java.util.List; @Service @@ -34,4 +39,29 @@ public List findAll() { .map(fileLogMapper::toDTO) .toList(); } + + /** + * Used by the /log/file page. Mirror of VisaLogService#findFiltered. + * Only non-null filters are added to the query, avoiding Hibernate 6/7's + * inability to bind null enum-typed parameters in JPQL. + */ + @PreAuthorize("isAuthenticated()") + public Page findFiltered(FileEventType eventType, + LocalDateTime from, + LocalDateTime to, + Pageable pageable) { + // Start from the "no filter" Specification. Spring Data JPA 4 deprecated + // the where(null) idiom in favour of this explicit factory method. + Specification spec = Specification.unrestricted(); + if (eventType != null) { + spec = spec.and((root, query, cb) -> cb.equal(root.get(FileLog_.fileEventType), eventType)); + } + if (from != null) { + spec = spec.and((root, query, cb) -> cb.greaterThanOrEqualTo(root.get(FileLog_.timeStamp), from)); + } + if (to != null) { + spec = spec.and((root, query, cb) -> cb.lessThanOrEqualTo(root.get(FileLog_.timeStamp), to)); + } + return fileLogRepository.findAll(spec, pageable).map(fileLogMapper::toDTO); + } } diff --git a/src/main/java/org/example/visacasemanagementsystem/audit/service/VisaLogService.java b/src/main/java/org/example/visacasemanagementsystem/audit/service/VisaLogService.java index 15b94b4..67eb2c1 100644 --- a/src/main/java/org/example/visacasemanagementsystem/audit/service/VisaLogService.java +++ b/src/main/java/org/example/visacasemanagementsystem/audit/service/VisaLogService.java @@ -44,6 +44,7 @@ public List findAll() { * only non-null filters are added to the query, so Hibernate never has to * bind a null enum parameter (which fails in Hibernate 6/7). */ + @PreAuthorize("isAuthenticated()") public Page findFiltered(VisaEventType eventType, LocalDateTime from, LocalDateTime to, diff --git a/src/main/resources/templates/fragments/header.html b/src/main/resources/templates/fragments/header.html index 138d5db..fafae46 100644 --- a/src/main/resources/templates/fragments/header.html +++ b/src/main/resources/templates/fragments/header.html @@ -26,6 +26,8 @@ Visa Log User Log + Comment Log + File Log User List Visa Cases Profile diff --git a/src/main/resources/templates/log/comment.html b/src/main/resources/templates/log/comment.html new file mode 100644 index 0000000..344c4c5 --- /dev/null +++ b/src/main/resources/templates/log/comment.html @@ -0,0 +1,118 @@ + + + + + Comment Log + + + + + + + +
+
+
+
+

Comment Log

+ + 0 events total + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Reset +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
IDTimestampActor User IDVisa Case IDComment IDEventDescription
12026-04-24 10:00421017 + ADDED + Comment added.
+ +
+ No log entries match the current filters. +
+ + +
+ ← Previous + + + Page 1 of 1 + + + Next → +
+
+
+
+ + + diff --git a/src/main/resources/templates/log/file.html b/src/main/resources/templates/log/file.html new file mode 100644 index 0000000..0d57b2d --- /dev/null +++ b/src/main/resources/templates/log/file.html @@ -0,0 +1,118 @@ + + + + + File Log + + + + + + + +
+
+
+
+

File Log

+ + 0 events total + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Reset +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
IDTimestampActor User IDVisa Case IDFile NameEventDescription
12026-04-24 10:0042101passport.pdf + UPLOADED + File uploaded.
+ +
+ No log entries match the current filters. +
+ + +
+ ← Previous + + + Page 1 of 1 + + + Next → +
+
+
+
+ + + diff --git a/src/test/java/org/example/visacasemanagementsystem/audit/controller/LogViewControllerTest.java b/src/test/java/org/example/visacasemanagementsystem/audit/controller/LogViewControllerTest.java index 72aaffa..b997280 100644 --- a/src/test/java/org/example/visacasemanagementsystem/audit/controller/LogViewControllerTest.java +++ b/src/test/java/org/example/visacasemanagementsystem/audit/controller/LogViewControllerTest.java @@ -1,9 +1,15 @@ package org.example.visacasemanagementsystem.audit.controller; +import org.example.visacasemanagementsystem.audit.CommentEventType; +import org.example.visacasemanagementsystem.audit.FileEventType; import org.example.visacasemanagementsystem.audit.UserEventType; import org.example.visacasemanagementsystem.audit.VisaEventType; +import org.example.visacasemanagementsystem.audit.dto.CommentLogDTO; +import org.example.visacasemanagementsystem.audit.dto.FileLogDTO; import org.example.visacasemanagementsystem.audit.dto.UserLogDTO; import org.example.visacasemanagementsystem.audit.dto.VisaLogDTO; +import org.example.visacasemanagementsystem.audit.service.CommentLogService; +import org.example.visacasemanagementsystem.audit.service.FileLogService; import org.example.visacasemanagementsystem.audit.service.UserLogService; import org.example.visacasemanagementsystem.audit.service.VisaLogService; import org.example.visacasemanagementsystem.user.UserAuthorization; @@ -70,6 +76,12 @@ class LogViewControllerTest { @MockitoBean private UserLogService userLogService; + @MockitoBean + private CommentLogService commentLogService; + + @MockitoBean + private FileLogService fileLogService; + @AfterEach void clearSecurityContext() { SecurityContextHolder.clearContext(); @@ -264,4 +276,156 @@ void userLog_AsSysadmin_WithFilters_ShouldPassThroughToService() throws Exceptio any(Pageable.class)); } + // ── /log/comment ────────────────────────────────────────────────────── + + @Test + void commentLog_AsSysadmin_NoFilters_ShouldReturnPageWithDefaults() throws Exception { + // Arrange + loginAsSysadmin(); + Page empty = new PageImpl<>(List.of(), PageRequest.of(0, 20), 0); + when(commentLogService.findFiltered(isNull(), isNull(), isNull(), any(Pageable.class))) + .thenReturn(empty); + + // Act & Assert + mockMvc.perform(get("/log/comment")) + .andExpect(status().isOk()) + .andExpect(view().name("log/comment")) + .andExpect(model().attributeExists("logs")) + .andExpect(model().attribute("eventTypes", CommentEventType.values())) + .andExpect(model().attribute("selectedEventType", (Object) null)) + .andExpect(model().attribute("from", (Object) null)) + .andExpect(model().attribute("to", (Object) null)); + + Pageable expected = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "timeStamp")); + verify(commentLogService).findFiltered(isNull(), isNull(), isNull(), eq(expected)); + } + + @Test + void commentLog_AsSysadmin_WithFilters_ShouldPassThroughToService() throws Exception { + // Arrange + loginAsSysadmin(); + LocalDate from = LocalDate.of(2026, 4, 1); + LocalDate to = LocalDate.of(2026, 4, 24); + LocalDateTime expectedFrom = from.atStartOfDay(); + LocalDateTime expectedTo = to.atTime(LocalTime.MAX); + + CommentLogDTO sample = new CommentLogDTO( + 1L, LocalDateTime.now(), 42L, 101L, 7L, + CommentEventType.ADDED, "Comment added."); + Page page = new PageImpl<>(List.of(sample), PageRequest.of(0, 20), 1); + when(commentLogService.findFiltered( + eq(CommentEventType.ADDED), eq(expectedFrom), eq(expectedTo), any(Pageable.class))) + .thenReturn(page); + + // Act & Assert + mockMvc.perform(get("/log/comment") + .param("eventType", "ADDED") + .param("from", "2026-04-01") + .param("to", "2026-04-24")) + .andExpect(status().isOk()) + .andExpect(view().name("log/comment")) + .andExpect(model().attribute("selectedEventType", CommentEventType.ADDED)) + .andExpect(model().attribute("from", from)) + .andExpect(model().attribute("to", to)); + + verify(commentLogService).findFiltered( + eq(CommentEventType.ADDED), + eq(expectedFrom), + eq(expectedTo), + any(Pageable.class)); + } + + @Test + void commentLog_AsSysadmin_WithOversizedPageSize_ShouldClampToMax() throws Exception { + // Arrange — size=1000 should be clamped to MAX_PAGE_SIZE (100). + loginAsSysadmin(); + Page empty = new PageImpl<>(List.of(), PageRequest.of(0, 100), 0); + when(commentLogService.findFiltered(isNull(), isNull(), isNull(), any(Pageable.class))) + .thenReturn(empty); + + // Act & Assert + mockMvc.perform(get("/log/comment").param("size", "1000")) + .andExpect(status().isOk()); + + Pageable expected = PageRequest.of(0, 100, Sort.by(Sort.Direction.DESC, "timeStamp")); + verify(commentLogService).findFiltered(isNull(), isNull(), isNull(), eq(expected)); + } + + // ── /log/file ───────────────────────────────────────────────────────── + + @Test + void fileLog_AsSysadmin_NoFilters_ShouldReturnPageWithDefaults() throws Exception { + // Arrange + loginAsSysadmin(); + Page empty = new PageImpl<>(List.of(), PageRequest.of(0, 20), 0); + when(fileLogService.findFiltered(isNull(), isNull(), isNull(), any(Pageable.class))) + .thenReturn(empty); + + // Act & Assert + mockMvc.perform(get("/log/file")) + .andExpect(status().isOk()) + .andExpect(view().name("log/file")) + .andExpect(model().attributeExists("logs")) + .andExpect(model().attribute("eventTypes", FileEventType.values())) + .andExpect(model().attribute("selectedEventType", (Object) null)) + .andExpect(model().attribute("from", (Object) null)) + .andExpect(model().attribute("to", (Object) null)); + + Pageable expected = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "timeStamp")); + verify(fileLogService).findFiltered(isNull(), isNull(), isNull(), eq(expected)); + } + + @Test + void fileLog_AsSysadmin_WithFilters_ShouldPassThroughToService() throws Exception { + // Arrange + loginAsSysadmin(); + LocalDate from = LocalDate.of(2026, 4, 1); + LocalDate to = LocalDate.of(2026, 4, 24); + LocalDateTime expectedFrom = from.atStartOfDay(); + LocalDateTime expectedTo = to.atTime(LocalTime.MAX); + + FileLogDTO sample = new FileLogDTO( + 1L, LocalDateTime.now(), 42L, 101L, "passport.pdf", + FileEventType.UPLOADED, "File uploaded."); + Page page = new PageImpl<>(List.of(sample), PageRequest.of(0, 20), 1); + when(fileLogService.findFiltered( + eq(FileEventType.UPLOADED), eq(expectedFrom), eq(expectedTo), any(Pageable.class))) + .thenReturn(page); + + // Act & Assert + mockMvc.perform(get("/log/file") + .param("eventType", "UPLOADED") + .param("from", "2026-04-01") + .param("to", "2026-04-24")) + .andExpect(status().isOk()) + .andExpect(view().name("log/file")) + .andExpect(model().attribute("selectedEventType", FileEventType.UPLOADED)) + .andExpect(model().attribute("from", from)) + .andExpect(model().attribute("to", to)); + + verify(fileLogService).findFiltered( + eq(FileEventType.UPLOADED), + eq(expectedFrom), + eq(expectedTo), + any(Pageable.class)); + } + + @Test + void fileLog_AsSysadmin_WithExplicitPageAndSize_ShouldUseThem() throws Exception { + // Arrange + loginAsSysadmin(); + Page empty = new PageImpl<>(List.of(), PageRequest.of(2, 50), 0); + when(fileLogService.findFiltered(isNull(), isNull(), isNull(), any(Pageable.class))) + .thenReturn(empty); + + // Act & Assert + mockMvc.perform(get("/log/file") + .param("page", "2") + .param("size", "50")) + .andExpect(status().isOk()); + + Pageable expected = PageRequest.of(2, 50, Sort.by(Sort.Direction.DESC, "timeStamp")); + verify(fileLogService).findFiltered(isNull(), isNull(), isNull(), eq(expected)); + } + } diff --git a/src/test/java/org/example/visacasemanagementsystem/audit/service/CommentLogServiceTest.java b/src/test/java/org/example/visacasemanagementsystem/audit/service/CommentLogServiceTest.java index edcc46a..daa2656 100644 --- a/src/test/java/org/example/visacasemanagementsystem/audit/service/CommentLogServiceTest.java +++ b/src/test/java/org/example/visacasemanagementsystem/audit/service/CommentLogServiceTest.java @@ -1,5 +1,11 @@ package org.example.visacasemanagementsystem.audit.service; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Path; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import jakarta.persistence.metamodel.SingularAttribute; import org.example.visacasemanagementsystem.audit.CommentEventType; import org.example.visacasemanagementsystem.audit.dto.CommentLogDTO; import org.example.visacasemanagementsystem.audit.entity.CommentLog; @@ -8,13 +14,23 @@ import org.junit.jupiter.api.DisplayName; 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.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import java.time.LocalDateTime; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -29,6 +45,8 @@ class CommentLogServiceTest { @InjectMocks private CommentLogService commentLogService; + // ── createCommentLog ────────────────────────────────────────────────────── + @Test @DisplayName("createCommentLog should map to entity and save to repository") void createComment_shouldMapAndSaveLog() { @@ -51,6 +69,25 @@ void createComment_shouldMapAndSaveLog() { verify(commentLogRepository, times(1)).save(mockEntity); } + @Test + @DisplayName("Checking if createCommentLog passes the exact same entity instance from mapper to repository") + void createCommentLog_shouldPassSameEntityInstance_WhenSavingToRepository() { + // Arrange + CommentLog mappedEntity = new CommentLog(); + when(commentLogMapper.toEntity(any(), any(), any(), any(), any())) + .thenReturn(mappedEntity); + + // Act + commentLogService.createCommentLog(1L, 1L, 1L, CommentEventType.ADDED, "x"); + + // Assert + ArgumentCaptor captor = ArgumentCaptor.forClass(CommentLog.class); + verify(commentLogRepository).save(captor.capture()); + assertThat(captor.getValue()).isSameAs(mappedEntity); + } + + // ── findAll ─────────────────────────────────────────────────────────────── + @Test @DisplayName("findAll should return a list of DTOs") void findAll_shouldReturnDtoToList() { @@ -69,4 +106,226 @@ void findAll_shouldReturnDtoToList() { verify(commentLogRepository, times(1)).findAll(); verify(commentLogMapper, times(2)).toDTO(any(CommentLog.class)); } + + @Test + @DisplayName("Checking if findAll returns an empty list when repository has no entries") + void findAll_shouldReturnEmptyList_WhenRepositoryIsEmpty() { + // Arrange + when(commentLogRepository.findAll()).thenReturn(List.of()); + + // Act + List result = commentLogService.findAll(); + + // Assert + assertThat(result).isEmpty(); + verifyNoInteractions(commentLogMapper); + } + + // ── findFiltered ────────────────────────────────────────────────────────── + + @Test + @DisplayName("findFiltered: delegates to repository with a non-null spec and maps the page content when all filters are null") + @SuppressWarnings("unchecked") + void findFiltered_shouldDelegateToRepo_andMapPage_WhenAllFiltersAreNull() { + // Arrange + Pageable pageable = PageRequest.of(0, 20); + CommentLog entity = new CommentLog(); + entity.setId(1L); + CommentLogDTO dto = new CommentLogDTO( + 1L, LocalDateTime.now(), 1L, 10L, 7L, CommentEventType.ADDED, "x"); + + when(commentLogRepository.findAll(any(Specification.class), eq(pageable))) + .thenReturn(new PageImpl<>(List.of(entity), pageable, 1)); + when(commentLogMapper.toDTO(entity)).thenReturn(dto); + + // Act + Page result = commentLogService.findFiltered(null, null, null, pageable); + + // Assert + assertThat(result.getContent()).containsExactly(dto); + assertThat(result.getPageable()).isEqualTo(pageable); + assertThat(result.getTotalElements()).isEqualTo(1); + + ArgumentCaptor> specCaptor = + ArgumentCaptor.forClass(Specification.class); + verify(commentLogRepository).findAll(specCaptor.capture(), eq(pageable)); + assertThat(specCaptor.getValue()) + .as("service must never pass a null Specification") + .isNotNull(); + } + + @Test + @DisplayName("findFiltered: returns an empty page and skips the mapper when the repository returns nothing") + @SuppressWarnings("unchecked") + void findFiltered_shouldReturnEmptyPage_WhenRepositoryHasNoMatches() { + // Arrange + Pageable pageable = PageRequest.of(1, 50); + when(commentLogRepository.findAll(any(Specification.class), eq(pageable))) + .thenReturn(new PageImpl<>(List.of(), pageable, 0)); + + // Act + Page result = commentLogService.findFiltered( + CommentEventType.ADDED, null, null, pageable); + + // Assert + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isZero(); + verifyNoInteractions(commentLogMapper); + } + + @Test + @DisplayName("findFiltered: builds an equality predicate on commentEventType when only eventType is set") + @SuppressWarnings({"unchecked", "rawtypes"}) + void findFiltered_shouldApplyEventTypeEqualityPredicate_WhenOnlyEventTypeIsSet() { + // Arrange + Pageable pageable = PageRequest.of(0, 10); + when(commentLogRepository.findAll(any(Specification.class), eq(pageable))) + .thenReturn(new PageImpl<>(List.of())); + + // Act + commentLogService.findFiltered(CommentEventType.ADDED, null, null, pageable); + + // Assert - capture the composed Specification and execute it against mocks + ArgumentCaptor> specCaptor = + ArgumentCaptor.forClass(Specification.class); + verify(commentLogRepository).findAll(specCaptor.capture(), eq(pageable)); + + Root root = mock(Root.class); + CriteriaQuery query = mock(CriteriaQuery.class); + CriteriaBuilder cb = mock(CriteriaBuilder.class); + Path path = mock(Path.class); + Predicate expectedPredicate = mock(Predicate.class); + + when(root.get(nullable(SingularAttribute.class))).thenReturn(path); + when(cb.equal(path, CommentEventType.ADDED)).thenReturn(expectedPredicate); + + Predicate actual = specCaptor.getValue().toPredicate(root, query, cb); + + assertThat(actual).isSameAs(expectedPredicate); + verify(cb).equal(path, CommentEventType.ADDED); + verify(cb, never()).greaterThanOrEqualTo(any(Path.class), any(LocalDateTime.class)); + verify(cb, never()).lessThanOrEqualTo(any(Path.class), any(LocalDateTime.class)); + } + + @Test + @DisplayName("findFiltered: builds a greaterThanOrEqualTo predicate on timeStamp when only from is set") + @SuppressWarnings({"unchecked", "rawtypes"}) + void findFiltered_shouldApplyFromPredicate_WhenOnlyFromIsSet() { + // Arrange + Pageable pageable = PageRequest.of(0, 10); + LocalDateTime from = LocalDateTime.of(2026, 1, 1, 0, 0); + when(commentLogRepository.findAll(any(Specification.class), eq(pageable))) + .thenReturn(new PageImpl<>(List.of())); + + // Act + commentLogService.findFiltered(null, from, null, pageable); + + // Assert + ArgumentCaptor> specCaptor = + ArgumentCaptor.forClass(Specification.class); + verify(commentLogRepository).findAll(specCaptor.capture(), eq(pageable)); + + Root root = mock(Root.class); + CriteriaQuery query = mock(CriteriaQuery.class); + CriteriaBuilder cb = mock(CriteriaBuilder.class); + Path path = mock(Path.class); + Predicate expectedPredicate = mock(Predicate.class); + + when(root.get(nullable(SingularAttribute.class))).thenReturn(path); + when(cb.greaterThanOrEqualTo(path, from)).thenReturn(expectedPredicate); + + Predicate actual = specCaptor.getValue().toPredicate(root, query, cb); + + assertThat(actual).isSameAs(expectedPredicate); + verify(cb).greaterThanOrEqualTo(path, from); + verify(cb, never()).equal(any(Path.class), any(CommentEventType.class)); + verify(cb, never()).lessThanOrEqualTo(any(Path.class), any(LocalDateTime.class)); + } + + @Test + @DisplayName("findFiltered: builds a lessThanOrEqualTo predicate on timeStamp when only to is set") + @SuppressWarnings({"unchecked", "rawtypes"}) + void findFiltered_shouldApplyToPredicate_WhenOnlyToIsSet() { + // Arrange + Pageable pageable = PageRequest.of(0, 10); + LocalDateTime to = LocalDateTime.of(2026, 12, 31, 23, 59); + when(commentLogRepository.findAll(any(Specification.class), eq(pageable))) + .thenReturn(new PageImpl<>(List.of())); + + // Act + commentLogService.findFiltered(null, null, to, pageable); + + // Assert + ArgumentCaptor> specCaptor = + ArgumentCaptor.forClass(Specification.class); + verify(commentLogRepository).findAll(specCaptor.capture(), eq(pageable)); + + Root root = mock(Root.class); + CriteriaQuery query = mock(CriteriaQuery.class); + CriteriaBuilder cb = mock(CriteriaBuilder.class); + Path path = mock(Path.class); + Predicate expectedPredicate = mock(Predicate.class); + + when(root.get(nullable(SingularAttribute.class))).thenReturn(path); + when(cb.lessThanOrEqualTo(path, to)).thenReturn(expectedPredicate); + + Predicate actual = specCaptor.getValue().toPredicate(root, query, cb); + + assertThat(actual).isSameAs(expectedPredicate); + verify(cb).lessThanOrEqualTo(path, to); + verify(cb, never()).equal(any(Path.class), any(CommentEventType.class)); + verify(cb, never()).greaterThanOrEqualTo(any(Path.class), any(LocalDateTime.class)); + } + + @Test + @DisplayName("findFiltered: composes event-type + from + to predicates with AND when all filters are supplied") + @SuppressWarnings({"unchecked", "rawtypes"}) + void findFiltered_shouldComposeAllPredicates_WhenAllFiltersAreSet() { + // Arrange + Pageable pageable = PageRequest.of(0, 10); + LocalDateTime from = LocalDateTime.of(2026, 1, 1, 0, 0); + LocalDateTime to = LocalDateTime.of(2026, 12, 31, 23, 59); + + when(commentLogRepository.findAll(any(Specification.class), eq(pageable))) + .thenReturn(new PageImpl<>(List.of())); + + // Act + commentLogService.findFiltered(CommentEventType.UPDATED, from, to, pageable); + + // Assert + ArgumentCaptor> specCaptor = + ArgumentCaptor.forClass(Specification.class); + verify(commentLogRepository).findAll(specCaptor.capture(), eq(pageable)); + + Root root = mock(Root.class); + CriteriaQuery query = mock(CriteriaQuery.class); + CriteriaBuilder cb = mock(CriteriaBuilder.class); + + Path eventTypePath = mock(Path.class); + Path timeStampPath = mock(Path.class); + Predicate eqPred = mock(Predicate.class); + Predicate gtePred = mock(Predicate.class); + Predicate ltePred = mock(Predicate.class); + Predicate and1 = mock(Predicate.class); + Predicate and2 = mock(Predicate.class); + + // The service's specification builder evaluates the three filters in a + // fixed order (event-type → from → to), and chains the resulting + // predicates with cb.and((eventType AND from), to). The multi-value + // thenReturn stubs below mirror that exact sequence — they would need + // updating if the order in CommentLogService#findFiltered ever changes. + when(root.get(nullable(SingularAttribute.class))).thenReturn(eventTypePath, timeStampPath, timeStampPath); + when(cb.equal(eventTypePath, CommentEventType.UPDATED)).thenReturn(eqPred); + when(cb.greaterThanOrEqualTo(timeStampPath, from)).thenReturn(gtePred); + when(cb.lessThanOrEqualTo(timeStampPath, to)).thenReturn(ltePred); + when(cb.and(eqPred, gtePred)).thenReturn(and1); + when(cb.and(and1, ltePred)).thenReturn(and2); + + Predicate actual = specCaptor.getValue().toPredicate(root, query, cb); + + assertThat(actual).isSameAs(and2); + verify(cb).equal(eventTypePath, CommentEventType.UPDATED); + verify(cb).greaterThanOrEqualTo(timeStampPath, from); + verify(cb).lessThanOrEqualTo(timeStampPath, to); + } } diff --git a/src/test/java/org/example/visacasemanagementsystem/audit/service/FileLogServiceTest.java b/src/test/java/org/example/visacasemanagementsystem/audit/service/FileLogServiceTest.java index 6a38804..ee77b36 100644 --- a/src/test/java/org/example/visacasemanagementsystem/audit/service/FileLogServiceTest.java +++ b/src/test/java/org/example/visacasemanagementsystem/audit/service/FileLogServiceTest.java @@ -1,5 +1,11 @@ package org.example.visacasemanagementsystem.audit.service; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Path; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import jakarta.persistence.metamodel.SingularAttribute; import org.example.visacasemanagementsystem.audit.FileEventType; import org.example.visacasemanagementsystem.audit.dto.FileLogDTO; import org.example.visacasemanagementsystem.audit.entity.FileLog; @@ -8,13 +14,23 @@ import org.junit.jupiter.api.DisplayName; 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.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import java.time.LocalDateTime; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -29,6 +45,8 @@ class FileLogServiceTest { @InjectMocks private FileLogService fileLogService; + // ── createFileLog ───────────────────────────────────────────────────────── + @Test @DisplayName("createFileLog should map to entity and save to repository") void createFileLog_shouldMapAndSave() { @@ -54,6 +72,25 @@ void createFileLog_shouldMapAndSave() { } + @Test + @DisplayName("Checking if createFileLog passes the exact same entity instance from mapper to repository") + void createFileLog_shouldPassSameEntityInstance_WhenSavingToRepository() { + // Arrange + FileLog mappedEntity = new FileLog(); + when(fileLogMapper.toEntity(any(), any(), any(), any(), any())) + .thenReturn(mappedEntity); + + // Act + fileLogService.createFileLog(1L, 1L, "doc.pdf", FileEventType.UPLOADED, "x"); + + // Assert + ArgumentCaptor captor = ArgumentCaptor.forClass(FileLog.class); + verify(fileLogRepository).save(captor.capture()); + assertThat(captor.getValue()).isSameAs(mappedEntity); + } + + // ── findAll ─────────────────────────────────────────────────────────────── + @Test @DisplayName("findAll should return a list of FileLogDTOs") void findAll_shouldReturnDtoList() { @@ -73,4 +110,226 @@ void findAll_shouldReturnDtoList() { verify(fileLogRepository, times(1)).findAll(); verify(fileLogMapper, times(2)).toDTO(any(FileLog.class)); } + + @Test + @DisplayName("Checking if findAll returns an empty list when repository has no entries") + void findAll_shouldReturnEmptyList_WhenRepositoryIsEmpty() { + // Arrange + when(fileLogRepository.findAll()).thenReturn(List.of()); + + // Act + List result = fileLogService.findAll(); + + // Assert + assertThat(result).isEmpty(); + verifyNoInteractions(fileLogMapper); + } + + // ── findFiltered ────────────────────────────────────────────────────────── + + @Test + @DisplayName("findFiltered: delegates to repository with a non-null spec and maps the page content when all filters are null") + @SuppressWarnings("unchecked") + void findFiltered_shouldDelegateToRepo_andMapPage_WhenAllFiltersAreNull() { + // Arrange + Pageable pageable = PageRequest.of(0, 20); + FileLog entity = new FileLog(); + entity.setId(1L); + FileLogDTO dto = new FileLogDTO( + 1L, LocalDateTime.now(), 1L, 10L, "passport.pdf", FileEventType.UPLOADED, "x"); + + when(fileLogRepository.findAll(any(Specification.class), eq(pageable))) + .thenReturn(new PageImpl<>(List.of(entity), pageable, 1)); + when(fileLogMapper.toDTO(entity)).thenReturn(dto); + + // Act + Page result = fileLogService.findFiltered(null, null, null, pageable); + + // Assert + assertThat(result.getContent()).containsExactly(dto); + assertThat(result.getPageable()).isEqualTo(pageable); + assertThat(result.getTotalElements()).isEqualTo(1); + + ArgumentCaptor> specCaptor = + ArgumentCaptor.forClass(Specification.class); + verify(fileLogRepository).findAll(specCaptor.capture(), eq(pageable)); + assertThat(specCaptor.getValue()) + .as("service must never pass a null Specification") + .isNotNull(); + } + + @Test + @DisplayName("findFiltered: returns an empty page and skips the mapper when the repository returns nothing") + @SuppressWarnings("unchecked") + void findFiltered_shouldReturnEmptyPage_WhenRepositoryHasNoMatches() { + // Arrange + Pageable pageable = PageRequest.of(1, 50); + when(fileLogRepository.findAll(any(Specification.class), eq(pageable))) + .thenReturn(new PageImpl<>(List.of(), pageable, 0)); + + // Act + Page result = fileLogService.findFiltered( + FileEventType.UPLOADED, null, null, pageable); + + // Assert + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isZero(); + verifyNoInteractions(fileLogMapper); + } + + @Test + @DisplayName("findFiltered: builds an equality predicate on fileEventType when only eventType is set") + @SuppressWarnings({"unchecked", "rawtypes"}) + void findFiltered_shouldApplyEventTypeEqualityPredicate_WhenOnlyEventTypeIsSet() { + // Arrange + Pageable pageable = PageRequest.of(0, 10); + when(fileLogRepository.findAll(any(Specification.class), eq(pageable))) + .thenReturn(new PageImpl<>(List.of())); + + // Act + fileLogService.findFiltered(FileEventType.UPLOADED, null, null, pageable); + + // Assert - capture the composed Specification and execute it against mocks + ArgumentCaptor> specCaptor = + ArgumentCaptor.forClass(Specification.class); + verify(fileLogRepository).findAll(specCaptor.capture(), eq(pageable)); + + Root root = mock(Root.class); + CriteriaQuery query = mock(CriteriaQuery.class); + CriteriaBuilder cb = mock(CriteriaBuilder.class); + Path path = mock(Path.class); + Predicate expectedPredicate = mock(Predicate.class); + + when(root.get(nullable(SingularAttribute.class))).thenReturn(path); + when(cb.equal(path, FileEventType.UPLOADED)).thenReturn(expectedPredicate); + + Predicate actual = specCaptor.getValue().toPredicate(root, query, cb); + + assertThat(actual).isSameAs(expectedPredicate); + verify(cb).equal(path, FileEventType.UPLOADED); + verify(cb, never()).greaterThanOrEqualTo(any(Path.class), any(LocalDateTime.class)); + verify(cb, never()).lessThanOrEqualTo(any(Path.class), any(LocalDateTime.class)); + } + + @Test + @DisplayName("findFiltered: builds a greaterThanOrEqualTo predicate on timeStamp when only from is set") + @SuppressWarnings({"unchecked", "rawtypes"}) + void findFiltered_shouldApplyFromPredicate_WhenOnlyFromIsSet() { + // Arrange + Pageable pageable = PageRequest.of(0, 10); + LocalDateTime from = LocalDateTime.of(2026, 1, 1, 0, 0); + when(fileLogRepository.findAll(any(Specification.class), eq(pageable))) + .thenReturn(new PageImpl<>(List.of())); + + // Act + fileLogService.findFiltered(null, from, null, pageable); + + // Assert + ArgumentCaptor> specCaptor = + ArgumentCaptor.forClass(Specification.class); + verify(fileLogRepository).findAll(specCaptor.capture(), eq(pageable)); + + Root root = mock(Root.class); + CriteriaQuery query = mock(CriteriaQuery.class); + CriteriaBuilder cb = mock(CriteriaBuilder.class); + Path path = mock(Path.class); + Predicate expectedPredicate = mock(Predicate.class); + + when(root.get(nullable(SingularAttribute.class))).thenReturn(path); + when(cb.greaterThanOrEqualTo(path, from)).thenReturn(expectedPredicate); + + Predicate actual = specCaptor.getValue().toPredicate(root, query, cb); + + assertThat(actual).isSameAs(expectedPredicate); + verify(cb).greaterThanOrEqualTo(path, from); + verify(cb, never()).equal(any(Path.class), any(FileEventType.class)); + verify(cb, never()).lessThanOrEqualTo(any(Path.class), any(LocalDateTime.class)); + } + + @Test + @DisplayName("findFiltered: builds a lessThanOrEqualTo predicate on timeStamp when only to is set") + @SuppressWarnings({"unchecked", "rawtypes"}) + void findFiltered_shouldApplyToPredicate_WhenOnlyToIsSet() { + // Arrange + Pageable pageable = PageRequest.of(0, 10); + LocalDateTime to = LocalDateTime.of(2026, 12, 31, 23, 59); + when(fileLogRepository.findAll(any(Specification.class), eq(pageable))) + .thenReturn(new PageImpl<>(List.of())); + + // Act + fileLogService.findFiltered(null, null, to, pageable); + + // Assert + ArgumentCaptor> specCaptor = + ArgumentCaptor.forClass(Specification.class); + verify(fileLogRepository).findAll(specCaptor.capture(), eq(pageable)); + + Root root = mock(Root.class); + CriteriaQuery query = mock(CriteriaQuery.class); + CriteriaBuilder cb = mock(CriteriaBuilder.class); + Path path = mock(Path.class); + Predicate expectedPredicate = mock(Predicate.class); + + when(root.get(nullable(SingularAttribute.class))).thenReturn(path); + when(cb.lessThanOrEqualTo(path, to)).thenReturn(expectedPredicate); + + Predicate actual = specCaptor.getValue().toPredicate(root, query, cb); + + assertThat(actual).isSameAs(expectedPredicate); + verify(cb).lessThanOrEqualTo(path, to); + verify(cb, never()).equal(any(Path.class), any(FileEventType.class)); + verify(cb, never()).greaterThanOrEqualTo(any(Path.class), any(LocalDateTime.class)); + } + + @Test + @DisplayName("findFiltered: composes event-type + from + to predicates with AND when all filters are supplied") + @SuppressWarnings({"unchecked", "rawtypes"}) + void findFiltered_shouldComposeAllPredicates_WhenAllFiltersAreSet() { + // Arrange + Pageable pageable = PageRequest.of(0, 10); + LocalDateTime from = LocalDateTime.of(2026, 1, 1, 0, 0); + LocalDateTime to = LocalDateTime.of(2026, 12, 31, 23, 59); + + when(fileLogRepository.findAll(any(Specification.class), eq(pageable))) + .thenReturn(new PageImpl<>(List.of())); + + // Act + fileLogService.findFiltered(FileEventType.DOWNLOADED, from, to, pageable); + + // Assert + ArgumentCaptor> specCaptor = + ArgumentCaptor.forClass(Specification.class); + verify(fileLogRepository).findAll(specCaptor.capture(), eq(pageable)); + + Root root = mock(Root.class); + CriteriaQuery query = mock(CriteriaQuery.class); + CriteriaBuilder cb = mock(CriteriaBuilder.class); + + Path eventTypePath = mock(Path.class); + Path timeStampPath = mock(Path.class); + Predicate eqPred = mock(Predicate.class); + Predicate gtePred = mock(Predicate.class); + Predicate ltePred = mock(Predicate.class); + Predicate and1 = mock(Predicate.class); + Predicate and2 = mock(Predicate.class); + + // The service's specification builder evaluates the three filters in a + // fixed order (event-type → from → to), and chains the resulting + // predicates with cb.and((eventType AND from), to). The multi-value + // thenReturn stubs below mirror that exact sequence — they would need + // updating if the order in FileLogService#findFiltered ever changes. + when(root.get(nullable(SingularAttribute.class))).thenReturn(eventTypePath, timeStampPath, timeStampPath); + when(cb.equal(eventTypePath, FileEventType.DOWNLOADED)).thenReturn(eqPred); + when(cb.greaterThanOrEqualTo(timeStampPath, from)).thenReturn(gtePred); + when(cb.lessThanOrEqualTo(timeStampPath, to)).thenReturn(ltePred); + when(cb.and(eqPred, gtePred)).thenReturn(and1); + when(cb.and(and1, ltePred)).thenReturn(and2); + + Predicate actual = specCaptor.getValue().toPredicate(root, query, cb); + + assertThat(actual).isSameAs(and2); + verify(cb).equal(eventTypePath, FileEventType.DOWNLOADED); + verify(cb).greaterThanOrEqualTo(timeStampPath, from); + verify(cb).lessThanOrEqualTo(timeStampPath, to); + } } From 745e17c77b9731b96e6867250d1ba0456bb0afdf Mon Sep 17 00:00:00 2001 From: Martin Karlsson Date: Mon, 27 Apr 2026 15:11:54 +0200 Subject: [PATCH 2/5] Fix: Sysadmin are now able to be assigned cases Feature: Comment Log now links to the Visa Case where the comment was made --- .../visa/service/VisaService.java | 12 ++++++------ src/main/resources/templates/log/comment.html | 9 +++++++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java b/src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java index 25f2cca..5a89df8 100644 --- a/src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java +++ b/src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java @@ -67,7 +67,7 @@ public VisaService(VisaRepository visaRepository, } // --- For filtering in Frontend list-view --- - @PreAuthorize("hasRole('ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN', 'SYSADMIN')") public List findAll() { return visaRepository.findAll(Sort.by(Sort.Direction.DESC, "updatedAt")) .stream() @@ -329,7 +329,7 @@ public void removeVisaDocument(Long visaId, String s3Key, Long userId) { } } - @PreAuthorize("hasRole('ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN', 'SYSADMIN')") @Transactional public VisaDTO approveVisa(Long visaId, Long adminId) { User admin = validateHandler(adminId); @@ -354,7 +354,7 @@ public VisaDTO approveVisa(Long visaId, Long adminId) { return visaMapper.toDTO(savedVisa); } - @PreAuthorize("hasRole('ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN', 'SYSADMIN')") @Transactional public VisaDTO rejectVisa(Long visaId, Long adminId, String reason) { if (reason == null || reason.isBlank()) { @@ -382,7 +382,7 @@ public VisaDTO rejectVisa(Long visaId, Long adminId, String reason) { return visaMapper.toDTO(savedVisa); } - @PreAuthorize("hasRole('ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN', 'SYSADMIN')") @Transactional public VisaDTO requestMoreInformation(Long visaId, Long adminId, String infoText) { if (infoText == null || infoText.isBlank()) { @@ -411,7 +411,7 @@ public VisaDTO requestMoreInformation(Long visaId, Long adminId, String infoText return visaMapper.toDTO(savedVisa); } - @PreAuthorize("hasRole('ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN', 'SYSADMIN')") @Transactional public VisaDTO assignHandler(Long visaId, Long adminId) { User admin = validateHandler(adminId); @@ -468,7 +468,7 @@ public List findVisasByApplicantId(Long applicantId) { .toList(); } - @PreAuthorize("hasRole('ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN', 'SYSADMIN')") public List findVisasByHandlerId(Long handlerId) { return visaRepository.findVisasByHandlerId(handlerId, Sort.by("updatedAt").descending()) diff --git a/src/main/resources/templates/log/comment.html b/src/main/resources/templates/log/comment.html index 344c4c5..88a3236 100644 --- a/src/main/resources/templates/log/comment.html +++ b/src/main/resources/templates/log/comment.html @@ -62,7 +62,7 @@

Comment Log

Timestamp Actor User ID Visa Case ID - Comment ID + Comment Event Description @@ -73,7 +73,12 @@

Comment Log

2026-04-24 10:00 42 101 - 7 + + View case + + Date: Mon, 27 Apr 2026 15:32:55 +0200 Subject: [PATCH 3/5] Fix: - Restricted all log service findFiltered methods to sysadmin only access - Refactored components and styles from html files to stylesheets --- .../audit/service/CommentLogService.java | 2 +- .../audit/service/FileLogService.java | 2 +- .../audit/service/UserLogService.java | 2 +- .../audit/service/VisaLogService.java | 2 +- src/main/resources/static/css/components.css | 111 ++++++++++++++++++ .../resources/templates/fragments/cases.html | 2 +- src/main/resources/templates/log/comment.html | 2 +- src/main/resources/templates/log/file.html | 2 +- src/main/resources/templates/log/user.html | 2 +- src/main/resources/templates/log/visa.html | 2 +- .../resources/templates/profile/edit.html | 2 +- src/main/resources/templates/user/list.html | 2 +- .../resources/templates/visa/apply-form.html | 15 +-- .../resources/templates/visa/details.html | 56 ++++++--- .../resources/templates/visa/edit-form.html | 19 ++- .../templates/visa/my-applications.html | 2 +- 16 files changed, 176 insertions(+), 49 deletions(-) diff --git a/src/main/java/org/example/visacasemanagementsystem/audit/service/CommentLogService.java b/src/main/java/org/example/visacasemanagementsystem/audit/service/CommentLogService.java index 4ed4650..0f61a9e 100644 --- a/src/main/java/org/example/visacasemanagementsystem/audit/service/CommentLogService.java +++ b/src/main/java/org/example/visacasemanagementsystem/audit/service/CommentLogService.java @@ -46,7 +46,7 @@ public List findAll() { * Only non-null filters are added to the query, avoiding Hibernate 6/7's * inability to bind null enum-typed parameters in JPQL. */ - @PreAuthorize("isAuthenticated()") + @PreAuthorize("hasRole('SYSADMIN')") public Page findFiltered(CommentEventType eventType, LocalDateTime from, LocalDateTime to, diff --git a/src/main/java/org/example/visacasemanagementsystem/audit/service/FileLogService.java b/src/main/java/org/example/visacasemanagementsystem/audit/service/FileLogService.java index bf36489..0256260 100644 --- a/src/main/java/org/example/visacasemanagementsystem/audit/service/FileLogService.java +++ b/src/main/java/org/example/visacasemanagementsystem/audit/service/FileLogService.java @@ -45,7 +45,7 @@ public List findAll() { * Only non-null filters are added to the query, avoiding Hibernate 6/7's * inability to bind null enum-typed parameters in JPQL. */ - @PreAuthorize("isAuthenticated()") + @PreAuthorize("hasRole('SYSADMIN')") public Page findFiltered(FileEventType eventType, LocalDateTime from, LocalDateTime to, diff --git a/src/main/java/org/example/visacasemanagementsystem/audit/service/UserLogService.java b/src/main/java/org/example/visacasemanagementsystem/audit/service/UserLogService.java index 7688604..7b7d6f7 100644 --- a/src/main/java/org/example/visacasemanagementsystem/audit/service/UserLogService.java +++ b/src/main/java/org/example/visacasemanagementsystem/audit/service/UserLogService.java @@ -44,7 +44,7 @@ public List findAll() { * Only non-null filters are added to the query, avoiding Hibernate 6/7's * inability to bind null enum-typed parameters in JPQL. */ - @PreAuthorize("isAuthenticated()") + @PreAuthorize("hasRole('SYSADMIN')") public Page findFiltered(UserEventType eventType, LocalDateTime from, LocalDateTime to, diff --git a/src/main/java/org/example/visacasemanagementsystem/audit/service/VisaLogService.java b/src/main/java/org/example/visacasemanagementsystem/audit/service/VisaLogService.java index 67eb2c1..5356c35 100644 --- a/src/main/java/org/example/visacasemanagementsystem/audit/service/VisaLogService.java +++ b/src/main/java/org/example/visacasemanagementsystem/audit/service/VisaLogService.java @@ -44,7 +44,7 @@ public List findAll() { * only non-null filters are added to the query, so Hibernate never has to * bind a null enum parameter (which fails in Hibernate 6/7). */ - @PreAuthorize("isAuthenticated()") + @PreAuthorize("hasRole('SYSADMIN')") public Page findFiltered(VisaEventType eventType, LocalDateTime from, LocalDateTime to, diff --git a/src/main/resources/static/css/components.css b/src/main/resources/static/css/components.css index 1034d85..2b54336 100644 --- a/src/main/resources/static/css/components.css +++ b/src/main/resources/static/css/components.css @@ -517,3 +517,114 @@ tr:hover td:first-child { border-left: 2px solid var(--color-text-primary); } padding: 40px; box-sizing: border-box; } + +/* ========================================================================== + * File-upload widget + * .file-upload-zone wraps a file and an optional filename display. + * Used on apply-form.html and edit-form.html. + * ========================================================================== */ +.file-upload-zone { + position: relative; + border: 1px dashed var(--color-border-strong); + background: var(--color-surface); + border-radius: var(--radius-sm); + padding: 12px; + display: flex; + align-items: center; + gap: 15px; +} + +/* Strip the global input defaults that don't apply inside the upload zone. */ +.file-upload-zone input[type="file"] { + border: none; + background: transparent; + padding: 0; + width: auto; + margin: 0; +} + +/* Filename text shown next to the picker button. */ +.file-name { + color: var(--color-text-primary); + font-size: 13px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Small hint below the upload zone ("Supported formats…"). */ +.upload-hint { + color: var(--color-text-dim); + font-size: 11px; + margin-top: 5px; +} + +/* ========================================================================== + * Document lists + * ========================================================================== */ +/* Horizontal flex row of document download chips (visa/details). */ +.doc-list { + display: flex; + gap: 10px; + margin-top: 10px; + flex-wrap: wrap; +} + +/* Vertical stack of document rows (visa/edit-form). */ +.doc-list-stack { + display: flex; + flex-direction: column; + gap: 10px; +} + +/* Missing-documents warning shown when no files are attached. */ +.no-docs-warning { + margin-top: 10px; + color: var(--color-danger); + font-size: 14px; + display: flex; + align-items: center; + gap: 8px; +} + +/* ========================================================================== + * Alert helpers + * Strip default

margin so alert text sits flush with the box padding. + * ========================================================================== */ +.alert-error p, +.alert-success p { margin: 0; } + +/* ========================================================================== + * Filter-bar submit button + * .btn-submit is full-width by default; inside .filter-bar it should + * shrink to fit its label. + * ========================================================================== */ +.filter-bar .btn-submit { + width: auto; + padding: 8px 16px; + margin-top: 0; +} + +/* ========================================================================== + * Utility helpers + * ========================================================================== */ +/* Right-align a table cell's content (action/link columns). */ +.td-right { text-align: right; } + +/* Vertical gap between two stacked .container cards on the same page. */ +.mt-card { margin-top: 24px; } + +/* Hides an element completely — used for hidden helper forms. */ +.hidden { display: none; } + +/* Inline-width variant of .btn-submit — for links that look like the + primary button but must not stretch to full column width. */ +.btn-submit-inline { + text-decoration: none; + display: inline-block; + width: auto; + padding: 12px 24px; + border-radius: var(--radius-md); + margin-top: 0; +} diff --git a/src/main/resources/templates/fragments/cases.html b/src/main/resources/templates/fragments/cases.html index 22ccf80..98bc11f 100644 --- a/src/main/resources/templates/fragments/cases.html +++ b/src/main/resources/templates/fragments/cases.html @@ -51,7 +51,7 @@

Cases

2026-05-20 - - + Details → diff --git a/src/main/resources/templates/log/comment.html b/src/main/resources/templates/log/comment.html index 88a3236..4d83e91 100644 --- a/src/main/resources/templates/log/comment.html +++ b/src/main/resources/templates/log/comment.html @@ -47,7 +47,7 @@

Comment Log

th:value="${to != null ? #temporals.format(to, 'yyyy-MM-dd') : ''}">
- Reset diff --git a/src/main/resources/templates/log/file.html b/src/main/resources/templates/log/file.html index 0d57b2d..b86a787 100644 --- a/src/main/resources/templates/log/file.html +++ b/src/main/resources/templates/log/file.html @@ -47,7 +47,7 @@

File Log

th:value="${to != null ? #temporals.format(to, 'yyyy-MM-dd') : ''}">
- Reset diff --git a/src/main/resources/templates/log/user.html b/src/main/resources/templates/log/user.html index a1f2de2..be3aa52 100644 --- a/src/main/resources/templates/log/user.html +++ b/src/main/resources/templates/log/user.html @@ -45,7 +45,7 @@

User Log

th:value="${to != null ? #temporals.format(to, 'yyyy-MM-dd') : ''}">
- Reset diff --git a/src/main/resources/templates/log/visa.html b/src/main/resources/templates/log/visa.html index e295f96..5a703d2 100644 --- a/src/main/resources/templates/log/visa.html +++ b/src/main/resources/templates/log/visa.html @@ -47,7 +47,7 @@

Visa Log

th:value="${to != null ? #temporals.format(to, 'yyyy-MM-dd') : ''}">
- Reset diff --git a/src/main/resources/templates/profile/edit.html b/src/main/resources/templates/profile/edit.html index 486aa9c..b41393d 100644 --- a/src/main/resources/templates/profile/edit.html +++ b/src/main/resources/templates/profile/edit.html @@ -70,7 +70,7 @@

Edit Profile

-
+

Authorization

Sysadmin only — change this user's role.

diff --git a/src/main/resources/templates/user/list.html b/src/main/resources/templates/user/list.html index d41af7d..3a6790d 100644 --- a/src/main/resources/templates/user/list.html +++ b/src/main/resources/templates/user/list.html @@ -51,7 +51,7 @@

User List

th:classappend="${'role-' + #strings.toLowerCase(user.userAuthorization().name())}" th:text="${user.userAuthorization()}">USER - + View → diff --git a/src/main/resources/templates/visa/apply-form.html b/src/main/resources/templates/visa/apply-form.html index a5189dc..4e56be4 100644 --- a/src/main/resources/templates/visa/apply-form.html +++ b/src/main/resources/templates/visa/apply-form.html @@ -33,7 +33,7 @@

New Application

-

+

@@ -80,17 +80,12 @@

New Application

-
- - - - +
+ +
-

- Supported Formats: PDF, JPG or PNG. (Max 10MB). -

+

Supported Formats: PDF, JPG or PNG. (Max 10MB).

diff --git a/src/main/resources/templates/visa/details.html b/src/main/resources/templates/visa/details.html index 49bcdef..3e593f9 100644 --- a/src/main/resources/templates/visa/details.html +++ b/src/main/resources/templates/visa/details.html @@ -47,17 +47,46 @@ border-color: var(--color-accent); } + /* When .btn-assign is used on an tag it needs link-specific resets. */ + a.btn-assign { + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + } + #infoReason { width: 100%; box-sizing: border-box; display: block; font-size: 12px; border-radius: var(--radius-md); + padding: 15px; + margin-bottom: 20px; } #infoReason:focus { border-color: var(--color-border-focus) !important; } .action-group { display: flex; gap: 10px; } + /* Admin heading inside the actions panel. */ + .admin-actions h3 { margin-top: 0; font-size: 20px; } + + /* Bordered sub-section for Reject / Request Info forms. */ + .decision-form { + margin-top: 20px; + border-top: 1px solid var(--color-border); + padding-top: 20px; + } + + /* Side-by-side form row used for the two decision buttons. */ + .form-row { display: flex; gap: 10px; width: 100%; } + .form-row form { flex: 1; } + .form-row button { width: 100%; transition: 0.2s ease; } + + /* Empty-comments placeholder. */ + .no-comments { color: var(--color-text-dimmer); padding: 20px 0; } + /* Comment section (kept inline — specific to this page). */ .comments-section { border-top: 1px solid var(--color-border); padding-top: 10px; font-size: 20px; font-weight: 600; } .comment-card { padding: 15px 0; border-bottom: 1px solid var(--color-surface); } @@ -160,17 +189,16 @@

Case #101

Attached Documents -
+ -
+
No documents attached. This may delay processing.
@@ -188,14 +216,13 @@

Action Required

+ class="btn-submit btn-submit-inline"> Edit Application
-

Case Management

+

Case Management

Case Management
-
+
-
- +
+ - -
+ -
@@ -251,7 +277,7 @@

Comments

-
+
No comments yet.
diff --git a/src/main/resources/templates/visa/edit-form.html b/src/main/resources/templates/visa/edit-form.html index 6e60d5c..24e805c 100644 --- a/src/main/resources/templates/visa/edit-form.html +++ b/src/main/resources/templates/visa/edit-form.html @@ -92,7 +92,7 @@

Edit Application

method="post" enctype="multipart/form-data">
-

+

@@ -127,7 +127,7 @@

Edit Application

-
+
@@ -144,17 +144,12 @@

Edit Application

-
- - - - +
+ +
-

- Supported Formats: PDF, JPG or PNG. (Max 10MB). -

+

Supported Formats: PDF, JPG or PNG. (Max 10MB).

@@ -164,7 +159,7 @@

Edit Application

+ method="post" class="hidden">
diff --git a/src/main/resources/templates/visa/my-applications.html b/src/main/resources/templates/visa/my-applications.html index 77e4972..7ab5d3a 100644 --- a/src/main/resources/templates/visa/my-applications.html +++ b/src/main/resources/templates/visa/my-applications.html @@ -54,7 +54,7 @@

My Applications

2026-05-20 - - +
Details → From 5fa14263b8cff9499d71f2c39d765291aff3d922 Mon Sep 17 00:00:00 2001 From: Martin Karlsson Date: Mon, 27 Apr 2026 15:58:03 +0200 Subject: [PATCH 4/5] Fix: - Restricted all log service findAll methods to sysadmin only access - Added sysadmin authorization to tests utilizing log service findAll methods - Extracted common elements from log templates to a log fragment --- .../audit/service/CommentLogService.java | 2 +- .../audit/service/FileLogService.java | 2 +- .../audit/service/VisaLogService.java | 2 +- .../visa/service/VisaService.java | 3 + .../templates/fragments/log-page.html | 105 ++++++++++++ src/main/resources/templates/log/comment.html | 152 +++++------------- src/main/resources/templates/log/file.html | 142 +++++----------- src/main/resources/templates/log/user.html | 134 +++++---------- src/main/resources/templates/log/visa.html | 138 +++++----------- .../visa/VisaServiceIntegrationTest.java | 20 +++ 10 files changed, 284 insertions(+), 416 deletions(-) create mode 100644 src/main/resources/templates/fragments/log-page.html diff --git a/src/main/java/org/example/visacasemanagementsystem/audit/service/CommentLogService.java b/src/main/java/org/example/visacasemanagementsystem/audit/service/CommentLogService.java index 0f61a9e..1a79244 100644 --- a/src/main/java/org/example/visacasemanagementsystem/audit/service/CommentLogService.java +++ b/src/main/java/org/example/visacasemanagementsystem/audit/service/CommentLogService.java @@ -33,7 +33,7 @@ public void createCommentLog(Long actorUserId, Long visaCaseId, Long commentId, } - @PreAuthorize("isAuthenticated()") + @PreAuthorize("hasRole('SYSADMIN')") public List findAll() { return commentLogRepository.findAll() .stream() diff --git a/src/main/java/org/example/visacasemanagementsystem/audit/service/FileLogService.java b/src/main/java/org/example/visacasemanagementsystem/audit/service/FileLogService.java index 0256260..e21e0f9 100644 --- a/src/main/java/org/example/visacasemanagementsystem/audit/service/FileLogService.java +++ b/src/main/java/org/example/visacasemanagementsystem/audit/service/FileLogService.java @@ -32,7 +32,7 @@ public void createFileLog(Long actorUserId, Long visaCaseId, String fileName, Fi } - @PreAuthorize("isAuthenticated()") + @PreAuthorize("hasRole('SYSADMIN')") public List findAll() { return fileLogRepository.findAll() .stream() diff --git a/src/main/java/org/example/visacasemanagementsystem/audit/service/VisaLogService.java b/src/main/java/org/example/visacasemanagementsystem/audit/service/VisaLogService.java index 5356c35..176e207 100644 --- a/src/main/java/org/example/visacasemanagementsystem/audit/service/VisaLogService.java +++ b/src/main/java/org/example/visacasemanagementsystem/audit/service/VisaLogService.java @@ -31,7 +31,7 @@ public void createVisaLog(Long userId, Long visaCaseId, VisaEventType visaEventT visaLogRepository.save(visaLog); } - @PreAuthorize("isAuthenticated()") + @PreAuthorize("hasRole('SYSADMIN')") public List findAll() { return visaLogRepository.findAll() .stream() diff --git a/src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java b/src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java index 5a89df8..4b1d530 100644 --- a/src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java +++ b/src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java @@ -329,6 +329,9 @@ public void removeVisaDocument(Long visaId, String s3Key, Long userId) { } } + // SYSADMIN is intentionally included in all business-action methods below. + // SYSADMINs can act as full admins when needed (e.g. covering for absent staff) + // while retaining their additional audit/user-management privileges. @PreAuthorize("hasAnyRole('ADMIN', 'SYSADMIN')") @Transactional public VisaDTO approveVisa(Long visaId, Long adminId) { diff --git a/src/main/resources/templates/fragments/log-page.html b/src/main/resources/templates/fragments/log-page.html new file mode 100644 index 0000000..e7da617 --- /dev/null +++ b/src/main/resources/templates/fragments/log-page.html @@ -0,0 +1,105 @@ + + + + + + + + + + +
+
+
+
+

Log

+ + 0 events total + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Reset +
+
+ +
+ + + +
+ +
+ No log entries match the current filters. +
+ + +
+ ← Previous + + + Page 1 of 1 + + + Next → +
+
+
+
+ +
+ + + diff --git a/src/main/resources/templates/log/comment.html b/src/main/resources/templates/log/comment.html index 4d83e91..7874a49 100644 --- a/src/main/resources/templates/log/comment.html +++ b/src/main/resources/templates/log/comment.html @@ -8,115 +8,49 @@ - - -
-
-
-
-

Comment Log

- - 0 events total - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - Reset -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - -
IDTimestampActor User IDVisa Case IDCommentEventDescription
12026-04-24 10:0042101 - View case - - - ADDED - Comment added.
- -
- No log entries match the current filters. -
- - -
- ← Previous - - - Page 1 of 1 - - - Next → -
-
-
+
+ + + + + + + + + + + + + + + + + + + + + + + +
IDTimestampActor User IDVisa Case IDCommentEventDescription
12026-04-24 10:0042101 + View case + + + ADDED + Comment added.
diff --git a/src/main/resources/templates/log/file.html b/src/main/resources/templates/log/file.html index b86a787..eff9953 100644 --- a/src/main/resources/templates/log/file.html +++ b/src/main/resources/templates/log/file.html @@ -8,110 +8,44 @@ - - -
-
-
-
-

File Log

- - 0 events total - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - Reset -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - -
IDTimestampActor User IDVisa Case IDFile NameEventDescription
12026-04-24 10:0042101passport.pdf - UPLOADED - File uploaded.
- -
- No log entries match the current filters. -
- - -
- ← Previous - - - Page 1 of 1 - - - Next → -
-
-
+
+ + + + + + + + + + + + + + + + + + + + + + + +
IDTimestampActor User IDVisa Case IDFile NameEventDescription
12026-04-24 10:0042101passport.pdf + UPLOADED + File uploaded.
diff --git a/src/main/resources/templates/log/user.html b/src/main/resources/templates/log/user.html index be3aa52..72e7500 100644 --- a/src/main/resources/templates/log/user.html +++ b/src/main/resources/templates/log/user.html @@ -8,104 +8,42 @@ - - -
-
-
-
-

User Log

- - 0 events total - -
-
- -
-
- - -
-
- - -
-
- - -
-
- - Reset -
-
- -
- - - - - - - - - - - - - - - - - - - - - -
IDTimestampActor User IDTarget User IDEventDescription
12026-04-24 10:004242 - CREATED - Account created via signup.
- -
- No log entries match the current filters. -
- -
- ← Previous - - - Page 1 of 1 - - - Next → -
-
-
+
+ + + + + + + + + + + + + + + + + + + + + +
IDTimestampActor User IDTarget User IDEventDescription
12026-04-24 10:004242 + CREATED + Account created via signup.
diff --git a/src/main/resources/templates/log/visa.html b/src/main/resources/templates/log/visa.html index 5a703d2..aafd9c2 100644 --- a/src/main/resources/templates/log/visa.html +++ b/src/main/resources/templates/log/visa.html @@ -8,108 +8,42 @@ - - -
-
-
-
-

Visa Log

- - 0 events total - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - Reset -
-
- -
- - - - - - - - - - - - - - - - - - - - - -
IDTimestampUser IDVisa Case IDEventDescription
12026-04-24 10:0042101 - CREATED - Visa application submitted.
- -
- No log entries match the current filters. -
- - -
- ← Previous - - - Page 1 of 1 - - - Next → -
-
-
+
+ + + + + + + + + + + + + + + + + + + + + +
IDTimestampUser IDVisa Case IDEventDescription
12026-04-24 10:0042101 + CREATED + Visa application submitted.
diff --git a/src/test/java/org/example/visacasemanagementsystem/visa/VisaServiceIntegrationTest.java b/src/test/java/org/example/visacasemanagementsystem/visa/VisaServiceIntegrationTest.java index 9877d22..dc30904 100644 --- a/src/test/java/org/example/visacasemanagementsystem/visa/VisaServiceIntegrationTest.java +++ b/src/test/java/org/example/visacasemanagementsystem/visa/VisaServiceIntegrationTest.java @@ -69,6 +69,7 @@ void applyForVisa_shouldSaveVisa_WhenDataIsValid() { assertThat(savedVisas).hasSize(1); assertThat(savedVisas.get(0).getPassportNumber()).isEqualTo("PASS-INT-123"); assertThat(savedVisas.get(0).getS3Keys()).contains(testS3Key); + authenticateAsSysadmin(); assertThat(visaLogService.findAll()).isNotEmpty(); } @@ -106,6 +107,7 @@ void updateVisa_shouldUpdateVisaAndResetStatus_WhenUserIsAuthorized() { assertThat(updatedVisa.getVisaStatus()).isEqualTo(VisaStatus.SUBMITTED); assertThat(updatedVisa.getStatusInformation()).isNull(); + authenticateAsSysadmin(); var logs = visaLogService.findAll(); Visa finalVisa = visa; @@ -153,6 +155,7 @@ void assignHandler_shouldAssignAdminToVisa_AndChangeStatusToAssigned() { assertThat(updatedVisa.getHandler().getId()).isEqualTo(admin.getId()); assertThat(updatedVisa.getVisaStatus()).isEqualTo(VisaStatus.ASSIGNED); + authenticateAsSysadmin(); var logs = visaLogService.findAll(); User finalAdmin = admin; assertThat(logs).anyMatch(log -> @@ -183,4 +186,21 @@ private UserPrincipal authenticateUser(User user) { return principal; } + /** + * Switches the security context to a SYSADMIN user so that calls to + * visaLogService.findAll() (which requires SYSADMIN) succeed during + * log-assertion steps without affecting the action under test. + */ + private void authenticateAsSysadmin() { + User sysadmin = new User(); + String email = java.util.UUID.randomUUID() + "@sysadmin-test.com"; + sysadmin.setFullName("Test Sysadmin"); + sysadmin.setEmail(email); + sysadmin.setUsername(email); + sysadmin.setPassword("password"); + sysadmin.setUserAuthorization(UserAuthorization.SYSADMIN); + sysadmin = userRepository.save(sysadmin); + authenticateUser(sysadmin); + } + } From daadfba39486161b91d594b3fa89c6dba78d234a Mon Sep 17 00:00:00 2001 From: Martin Karlsson Date: Mon, 27 Apr 2026 16:11:13 +0200 Subject: [PATCH 5/5] Fix: - Missing authorization check change on findAll in UserLogService --- .../visacasemanagementsystem/audit/service/UserLogService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/example/visacasemanagementsystem/audit/service/UserLogService.java b/src/main/java/org/example/visacasemanagementsystem/audit/service/UserLogService.java index 7b7d6f7..b757876 100644 --- a/src/main/java/org/example/visacasemanagementsystem/audit/service/UserLogService.java +++ b/src/main/java/org/example/visacasemanagementsystem/audit/service/UserLogService.java @@ -31,7 +31,7 @@ public void createUserLog(Long actorUserId, Long targetUserId, UserEventType use userLogRepository.save(userLog); } - @PreAuthorize("isAuthenticated()") + @PreAuthorize("hasRole('SYSADMIN')") public List findAll() { return userLogRepository.findAll() .stream()