feat(audit): add audit_log infrastructure and instrument AnnotationService
- V46 migration: audit_log table with indexes and append-only REVOKE - audit/ package: AuditKind enum (with Javadoc payloads), AuditLog entity, AuditLogRepository, AuditService (@Async on dedicated auditExecutor) - AsyncConfig: auditExecutor with CallerRunsPolicy and queueCapacity 50 - AnnotationService: ANNOTATION_CREATED on createAnnotation() only, deferred via afterCommit() when inside a transaction Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
package org.raddatz.familienarchiv.audit;
|
||||
|
||||
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 java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AuditServiceTest {
|
||||
|
||||
@Mock AuditLogRepository auditLogRepository;
|
||||
@InjectMocks AuditService auditService;
|
||||
|
||||
@Test
|
||||
void log_savesAuditRowWithCorrectFields() {
|
||||
UUID actorId = UUID.randomUUID();
|
||||
UUID documentId = UUID.randomUUID();
|
||||
Map<String, Object> payload = Map.of("pageNumber", 3);
|
||||
|
||||
when(auditLogRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
auditService.log(AuditKind.TEXT_SAVED, actorId, documentId, payload);
|
||||
|
||||
ArgumentCaptor<AuditLog> captor = ArgumentCaptor.forClass(AuditLog.class);
|
||||
verify(auditLogRepository).save(captor.capture());
|
||||
AuditLog saved = captor.getValue();
|
||||
|
||||
assertThat(saved.getKind()).isEqualTo(AuditKind.TEXT_SAVED);
|
||||
assertThat(saved.getActorId()).isEqualTo(actorId);
|
||||
assertThat(saved.getDocumentId()).isEqualTo(documentId);
|
||||
assertThat(saved.getPayload()).isEqualTo(payload);
|
||||
}
|
||||
|
||||
@Test
|
||||
void log_doesNotPropagateException_whenRepoThrows() {
|
||||
when(auditLogRepository.save(any())).thenThrow(new RuntimeException("DB down"));
|
||||
|
||||
assertThatCode(() ->
|
||||
auditService.log(AuditKind.METADATA_UPDATED, UUID.randomUUID(), UUID.randomUUID(), null)
|
||||
).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void log_acceptsNullPayload() {
|
||||
when(auditLogRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
auditService.log(AuditKind.FILE_UPLOADED, UUID.randomUUID(), UUID.randomUUID(), null);
|
||||
|
||||
ArgumentCaptor<AuditLog> captor = ArgumentCaptor.forClass(AuditLog.class);
|
||||
verify(auditLogRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getPayload()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void log_acceptsNullActorId() {
|
||||
when(auditLogRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
auditService.log(AuditKind.BLOCK_REVIEWED, null, UUID.randomUUID(), null);
|
||||
|
||||
ArgumentCaptor<AuditLog> captor = ArgumentCaptor.forClass(AuditLog.class);
|
||||
verify(auditLogRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getActorId()).isNull();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
|
||||
import org.raddatz.familienarchiv.dto.UpdateAnnotationDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
@@ -13,6 +16,8 @@ import org.raddatz.familienarchiv.repository.AnnotationRepository;
|
||||
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
@@ -32,6 +37,7 @@ class AnnotationServiceTest {
|
||||
|
||||
@Mock AnnotationRepository annotationRepository;
|
||||
@Mock TranscriptionBlockRepository blockRepository;
|
||||
@Mock AuditService auditService;
|
||||
@InjectMocks AnnotationService annotationService;
|
||||
|
||||
// ─── createAnnotation ─────────────────────────────────────────────────────
|
||||
@@ -89,6 +95,40 @@ class AnnotationServiceTest {
|
||||
assertThat(result.getFileHash()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createAnnotation_logsAnnotationCreatedWithPageNumber() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
CreateAnnotationDTO dto = new CreateAnnotationDTO(5, 0.1, 0.1, 0.3, 0.3, "#ff0000");
|
||||
DocumentAnnotation saved = DocumentAnnotation.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).pageNumber(5)
|
||||
.x(0.1).y(0.1).width(0.3).height(0.3).color("#ff0000").createdBy(userId).build();
|
||||
when(annotationRepository.save(any())).thenReturn(saved);
|
||||
|
||||
annotationService.createAnnotation(docId, dto, userId, null);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(Map.class);
|
||||
verify(auditService).log(
|
||||
org.mockito.ArgumentMatchers.eq(AuditKind.ANNOTATION_CREATED),
|
||||
org.mockito.ArgumentMatchers.eq(userId),
|
||||
org.mockito.ArgumentMatchers.eq(docId),
|
||||
payloadCaptor.capture());
|
||||
assertThat(payloadCaptor.getValue()).containsEntry("pageNumber", 5);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createOcrAnnotation_doesNotLogAuditEvent() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.1, 0.1, 0.8, 0.04, "#00C7B1");
|
||||
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
annotationService.createOcrAnnotation(docId, dto, userId, null, null);
|
||||
|
||||
verify(auditService, never()).log(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
// ─── createOcrAnnotation ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user