feat(audit): instrument DocumentService for METADATA_UPDATED, STATUS_CHANGED, FILE_UPLOADED
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m37s
CI / OCR Service Tests (push) Successful in 40s
CI / Backend Unit Tests (push) Failing after 2m53s
CI / Unit & Component Tests (pull_request) Failing after 2m32s
CI / OCR Service Tests (pull_request) Successful in 28s
CI / Backend Unit Tests (pull_request) Failing after 2m42s
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m37s
CI / OCR Service Tests (push) Successful in 40s
CI / Backend Unit Tests (push) Failing after 2m53s
CI / Unit & Component Tests (pull_request) Failing after 2m32s
CI / OCR Service Tests (pull_request) Successful in 28s
CI / Backend Unit Tests (pull_request) Failing after 2m42s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,8 @@ package org.raddatz.familienarchiv.service;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditService;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||||
@@ -10,6 +12,7 @@ import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
|||||||
import org.raddatz.familienarchiv.dto.MatchOffset;
|
import org.raddatz.familienarchiv.dto.MatchOffset;
|
||||||
import org.raddatz.familienarchiv.dto.SearchMatchData;
|
import org.raddatz.familienarchiv.dto.SearchMatchData;
|
||||||
import org.raddatz.familienarchiv.dto.TagOperator;
|
import org.raddatz.familienarchiv.dto.TagOperator;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.ScriptType;
|
import org.raddatz.familienarchiv.model.ScriptType;
|
||||||
@@ -20,6 +23,10 @@ import org.raddatz.familienarchiv.repository.DocumentRepository;
|
|||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -56,6 +63,8 @@ public class DocumentService {
|
|||||||
private final TagService tagService;
|
private final TagService tagService;
|
||||||
private final DocumentVersionService documentVersionService;
|
private final DocumentVersionService documentVersionService;
|
||||||
private final AnnotationService annotationService;
|
private final AnnotationService annotationService;
|
||||||
|
private final AuditService auditService;
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
public record StoreResult(Document document, boolean isNew) {}
|
public record StoreResult(Document document, boolean isNew) {}
|
||||||
|
|
||||||
@@ -108,11 +117,17 @@ public class DocumentService {
|
|||||||
document.setFilePath(upload.s3Key());
|
document.setFilePath(upload.s3Key());
|
||||||
document.setFileHash(upload.fileHash());
|
document.setFileHash(upload.fileHash());
|
||||||
document.setContentType(file.getContentType());
|
document.setContentType(file.getContentType());
|
||||||
if (document.getStatus() == DocumentStatus.PLACEHOLDER) {
|
boolean wasPlaceholder = document.getStatus() == DocumentStatus.PLACEHOLDER;
|
||||||
|
if (wasPlaceholder) {
|
||||||
document.setStatus(DocumentStatus.UPLOADED);
|
document.setStatus(DocumentStatus.UPLOADED);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new StoreResult(documentRepository.save(document), isNew);
|
Document saved = documentRepository.save(document);
|
||||||
|
if (wasPlaceholder) {
|
||||||
|
UUID actorId = resolveCurrentUserId();
|
||||||
|
logAfterCommit(AuditKind.FILE_UPLOADED, actorId, saved.getId(), null);
|
||||||
|
}
|
||||||
|
return new StoreResult(saved, isNew);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -192,6 +207,8 @@ public class DocumentService {
|
|||||||
Document doc = documentRepository.findById(id)
|
Document doc = documentRepository.findById(id)
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
||||||
|
|
||||||
|
DocumentStatus statusBefore = doc.getStatus();
|
||||||
|
|
||||||
// 1. Einfache Felder Update
|
// 1. Einfache Felder Update
|
||||||
doc.setTitle(dto.getTitle());
|
doc.setTitle(dto.getTitle());
|
||||||
doc.setDocumentDate(dto.getDocumentDate());
|
doc.setDocumentDate(dto.getDocumentDate());
|
||||||
@@ -245,6 +262,15 @@ public class DocumentService {
|
|||||||
|
|
||||||
Document saved = documentRepository.save(doc);
|
Document saved = documentRepository.save(doc);
|
||||||
documentVersionService.recordVersion(saved);
|
documentVersionService.recordVersion(saved);
|
||||||
|
|
||||||
|
UUID actorId = resolveCurrentUserId();
|
||||||
|
if (saved.getStatus() != statusBefore) {
|
||||||
|
logAfterCommit(AuditKind.STATUS_CHANGED, actorId, saved.getId(),
|
||||||
|
Map.of("oldStatus", statusBefore.name(), "newStatus", saved.getStatus().name()));
|
||||||
|
} else {
|
||||||
|
logAfterCommit(AuditKind.METADATA_UPDATED, actorId, saved.getId(), null);
|
||||||
|
}
|
||||||
|
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,11 +326,16 @@ public class DocumentService {
|
|||||||
doc.setFileHash(upload.fileHash());
|
doc.setFileHash(upload.fileHash());
|
||||||
doc.setOriginalFilename(file.getOriginalFilename());
|
doc.setOriginalFilename(file.getOriginalFilename());
|
||||||
doc.setContentType(file.getContentType());
|
doc.setContentType(file.getContentType());
|
||||||
if (doc.getStatus() == DocumentStatus.PLACEHOLDER) {
|
boolean wasPlaceholder = doc.getStatus() == DocumentStatus.PLACEHOLDER;
|
||||||
|
if (wasPlaceholder) {
|
||||||
doc.setStatus(DocumentStatus.UPLOADED);
|
doc.setStatus(DocumentStatus.UPLOADED);
|
||||||
}
|
}
|
||||||
Document saved = documentRepository.save(doc);
|
Document saved = documentRepository.save(doc);
|
||||||
documentVersionService.recordVersion(saved);
|
documentVersionService.recordVersion(saved);
|
||||||
|
if (wasPlaceholder) {
|
||||||
|
UUID actorId = resolveCurrentUserId();
|
||||||
|
logAfterCommit(AuditKind.FILE_UPLOADED, actorId, saved.getId(), null);
|
||||||
|
}
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -725,4 +756,26 @@ public class DocumentService {
|
|||||||
throw new IllegalStateException("SHA-256 not available", e);
|
throw new IllegalStateException("SHA-256 not available", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private UUID resolveCurrentUserId() {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth == null || !auth.isAuthenticated()) return null;
|
||||||
|
String email = auth.getName();
|
||||||
|
if (email == null) return null;
|
||||||
|
AppUser user = userService.findByEmail(email);
|
||||||
|
return user != null ? user.getId() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void logAfterCommit(AuditKind kind, UUID actorId, UUID documentId, Map<String, Object> payload) {
|
||||||
|
if (TransactionSynchronizationManager.isActualTransactionActive()) {
|
||||||
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void afterCommit() {
|
||||||
|
auditService.log(kind, actorId, documentId, payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
auditService.log(kind, actorId, documentId, payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import org.mockito.ArgumentCaptor;
|
|||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditService;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||||
@@ -13,6 +15,7 @@ import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
|||||||
import org.raddatz.familienarchiv.dto.MatchOffset;
|
import org.raddatz.familienarchiv.dto.MatchOffset;
|
||||||
import org.raddatz.familienarchiv.dto.SearchMatchData;
|
import org.raddatz.familienarchiv.dto.SearchMatchData;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
@@ -36,6 +39,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.isNull;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
@@ -47,6 +51,8 @@ class DocumentServiceTest {
|
|||||||
@Mock TagService tagService;
|
@Mock TagService tagService;
|
||||||
@Mock DocumentVersionService documentVersionService;
|
@Mock DocumentVersionService documentVersionService;
|
||||||
@Mock AnnotationService annotationService;
|
@Mock AnnotationService annotationService;
|
||||||
|
@Mock AuditService auditService;
|
||||||
|
@Mock UserService userService;
|
||||||
@InjectMocks DocumentService documentService;
|
@InjectMocks DocumentService documentService;
|
||||||
|
|
||||||
// ─── deleteDocument ───────────────────────────────────────────────────────
|
// ─── deleteDocument ───────────────────────────────────────────────────────
|
||||||
@@ -1499,4 +1505,86 @@ class DocumentServiceTest {
|
|||||||
assertThatThrownBy(() -> documentService.attachFile(id, file))
|
assertThatThrownBy(() -> documentService.attachFile(id, file))
|
||||||
.isInstanceOf(DomainException.class);
|
.isInstanceOf(DomainException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── audit events ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateDocument_logsMetadataUpdated_whenNoStatusChange() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(id).status(DocumentStatus.UPLOADED).build();
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(documentRepository.save(any())).thenReturn(doc);
|
||||||
|
|
||||||
|
documentService.updateDocument(id, new DocumentUpdateDTO(), null);
|
||||||
|
|
||||||
|
verify(auditService).log(eq(AuditKind.METADATA_UPDATED), isNull(), eq(id), isNull());
|
||||||
|
verify(auditService, never()).log(eq(AuditKind.STATUS_CHANGED), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateDocument_logsStatusChanged_whenFileCausesPlaceholderToUploadedTransition() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(id).status(DocumentStatus.PLACEHOLDER).build();
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> {
|
||||||
|
Document saved = inv.getArgument(0);
|
||||||
|
saved.setStatus(DocumentStatus.UPLOADED);
|
||||||
|
return saved;
|
||||||
|
});
|
||||||
|
MockMultipartFile file = new MockMultipartFile("file", "doc.pdf", "application/pdf", new byte[]{1});
|
||||||
|
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("key", "hash"));
|
||||||
|
|
||||||
|
documentService.updateDocument(id, new DocumentUpdateDTO(), file);
|
||||||
|
|
||||||
|
verify(auditService).log(eq(AuditKind.STATUS_CHANGED), isNull(), eq(id), any());
|
||||||
|
verify(auditService, never()).log(eq(AuditKind.METADATA_UPDATED), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeDocument_logsFileUploaded_whenExistingDocumentWasPlaceholder() throws Exception {
|
||||||
|
String filename = "test.pdf";
|
||||||
|
Document existing = Document.builder()
|
||||||
|
.id(UUID.randomUUID()).originalFilename(filename).status(DocumentStatus.PLACEHOLDER).build();
|
||||||
|
when(documentRepository.findFirstByOriginalFilename(filename)).thenReturn(Optional.of(existing));
|
||||||
|
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("s3key", "hash"));
|
||||||
|
when(documentRepository.save(any())).thenReturn(existing);
|
||||||
|
|
||||||
|
MockMultipartFile file = new MockMultipartFile("file", filename, "application/pdf", new byte[]{1});
|
||||||
|
documentService.storeDocument(file);
|
||||||
|
|
||||||
|
verify(auditService).log(eq(AuditKind.FILE_UPLOADED), isNull(), eq(existing.getId()), isNull());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeDocument_doesNotLogFileUploaded_whenDocumentIsAlreadyUploaded() throws Exception {
|
||||||
|
String filename = "test.pdf";
|
||||||
|
Document existing = Document.builder()
|
||||||
|
.id(UUID.randomUUID()).originalFilename(filename).status(DocumentStatus.UPLOADED).build();
|
||||||
|
when(documentRepository.findFirstByOriginalFilename(filename)).thenReturn(Optional.of(existing));
|
||||||
|
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("s3key", "hash"));
|
||||||
|
when(documentRepository.save(any())).thenReturn(existing);
|
||||||
|
|
||||||
|
MockMultipartFile file = new MockMultipartFile("file", filename, "application/pdf", new byte[]{1});
|
||||||
|
documentService.storeDocument(file);
|
||||||
|
|
||||||
|
verify(auditService, never()).log(any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void attachFile_logsFileUploaded_whenDocumentWasPlaceholder() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(id).status(DocumentStatus.PLACEHOLDER).build();
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("s3key", "hash"));
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> {
|
||||||
|
Document saved = inv.getArgument(0);
|
||||||
|
saved.setStatus(DocumentStatus.UPLOADED);
|
||||||
|
return saved;
|
||||||
|
});
|
||||||
|
|
||||||
|
MockMultipartFile file = new MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
|
||||||
|
documentService.attachFile(id, file);
|
||||||
|
|
||||||
|
verify(auditService).log(eq(AuditKind.FILE_UPLOADED), isNull(), eq(id), isNull());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user