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:
Marcel
2026-04-19 13:17:54 +02:00
parent 692c2c0629
commit 793b863096
9 changed files with 277 additions and 1 deletions

View File

@@ -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();
}
}

View File

@@ -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