fix(document): record actorId on DOCUMENT_DELETED audit (#805)
deleteDocument now threads the acting user through to the audit log so DOCUMENT_DELETED records who deleted the document, matching every other audited write path (storeDocument, updateDocument, applyBulkEditToDocument, attachFile). Tightens the AC-7 assertion from any() to the concrete actor. Also adds the missing ApplicationEventPublisher @Mock to DocumentServiceTest, which the publishEvent call (added with the cascade fix) left null. Addresses @nora (CWE-778, blocker), @felix and @sara review on PR #806. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -168,8 +168,8 @@ public class DocumentController {
|
|||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public ResponseEntity<Void> deleteDocument(@PathVariable UUID id) {
|
public ResponseEntity<Void> deleteDocument(@PathVariable UUID id, Authentication authentication) {
|
||||||
documentService.deleteDocument(id);
|
documentService.deleteDocument(id, requireUserId(authentication));
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1098,13 +1098,13 @@ public class DocumentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteDocument(UUID id) {
|
public void deleteDocument(UUID id, UUID actorId) {
|
||||||
if (!documentRepository.existsById(id)) {
|
if (!documentRepository.existsById(id)) {
|
||||||
throw DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id);
|
throw DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id);
|
||||||
}
|
}
|
||||||
eventPublisher.publishEvent(new DocumentDeletingEvent(id));
|
eventPublisher.publishEvent(new DocumentDeletingEvent(id));
|
||||||
documentRepository.deleteById(id);
|
documentRepository.deleteById(id);
|
||||||
auditService.logAfterCommit(AuditKind.DOCUMENT_DELETED, null, id, null);
|
auditService.logAfterCommit(AuditKind.DOCUMENT_DELETED, actorId, id, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
|
|||||||
@@ -402,6 +402,7 @@ class DocumentControllerTest {
|
|||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void deleteDocument_returns204_whenHasWritePermission() throws Exception {
|
void deleteDocument_returns204_whenHasWritePermission() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||||
.delete("/api/documents/" + id).with(csrf()))
|
.delete("/api/documents/" + id).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import org.raddatz.familienarchiv.document.DocumentRepository;
|
|||||||
import org.raddatz.familienarchiv.filestorage.FileService;
|
import org.raddatz.familienarchiv.filestorage.FileService;
|
||||||
import org.raddatz.familienarchiv.tag.TagService;
|
import org.raddatz.familienarchiv.tag.TagService;
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageImpl;
|
import org.springframework.data.domain.PageImpl;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
@@ -75,6 +76,7 @@ class DocumentServiceTest {
|
|||||||
@Mock AuditLogQueryService auditLogQueryService;
|
@Mock AuditLogQueryService auditLogQueryService;
|
||||||
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
|
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
|
||||||
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
|
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||||
|
@Mock ApplicationEventPublisher eventPublisher;
|
||||||
// Real factory (pure, dependency-free) so save-time title-regeneration tests exercise the
|
// Real factory (pure, dependency-free) so save-time title-regeneration tests exercise the
|
||||||
// shared composition rather than a stub — the #726 single source of truth.
|
// shared composition rather than a stub — the #726 single source of truth.
|
||||||
@Spy DocumentTitleFactory documentTitleFactory = new DocumentTitleFactory();
|
@Spy DocumentTitleFactory documentTitleFactory = new DocumentTitleFactory();
|
||||||
@@ -87,7 +89,7 @@ class DocumentServiceTest {
|
|||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
when(documentRepository.existsById(id)).thenReturn(true);
|
when(documentRepository.existsById(id)).thenReturn(true);
|
||||||
|
|
||||||
documentService.deleteDocument(id);
|
documentService.deleteDocument(id, UUID.randomUUID());
|
||||||
|
|
||||||
verify(documentRepository).deleteById(id);
|
verify(documentRepository).deleteById(id);
|
||||||
}
|
}
|
||||||
@@ -97,7 +99,7 @@ class DocumentServiceTest {
|
|||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
when(documentRepository.existsById(id)).thenReturn(false);
|
when(documentRepository.existsById(id)).thenReturn(false);
|
||||||
|
|
||||||
assertThatThrownBy(() -> documentService.deleteDocument(id))
|
assertThatThrownBy(() -> documentService.deleteDocument(id, UUID.randomUUID()))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.hasMessageContaining(id.toString());
|
.hasMessageContaining(id.toString());
|
||||||
verify(documentRepository, never()).deleteById(any());
|
verify(documentRepository, never()).deleteById(any());
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ class JourneyItemDocumentDeleteTest {
|
|||||||
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
|
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
documentService.deleteDocument(doc.getId());
|
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||||
|
|
||||||
assertThat(journeyItemRepository.findById(item.getId())).isEmpty();
|
assertThat(journeyItemRepository.findById(item.getId())).isEmpty();
|
||||||
assertThat(docRepo.findById(doc.getId())).isEmpty();
|
assertThat(docRepo.findById(doc.getId())).isEmpty();
|
||||||
@@ -123,7 +123,7 @@ class JourneyItemDocumentDeleteTest {
|
|||||||
JourneyItem.builder().geschichte(journey).position(10).document(doc).note("curator context").build());
|
JourneyItem.builder().geschichte(journey).position(10).document(doc).note("curator context").build());
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
documentService.deleteDocument(doc.getId());
|
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
JourneyItem surviving = journeyItemRepository.findById(item.getId()).orElseThrow();
|
JourneyItem surviving = journeyItemRepository.findById(item.getId()).orElseThrow();
|
||||||
@@ -139,7 +139,7 @@ class JourneyItemDocumentDeleteTest {
|
|||||||
JourneyItem.builder().geschichte(journey).position(10).note("Einleitung").build());
|
JourneyItem.builder().geschichte(journey).position(10).note("Einleitung").build());
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
documentService.deleteDocument(doc.getId());
|
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
JourneyItem reloaded = journeyItemRepository.findById(noteOnly.getId()).orElseThrow();
|
JourneyItem reloaded = journeyItemRepository.findById(noteOnly.getId()).orElseThrow();
|
||||||
@@ -163,7 +163,7 @@ class JourneyItemDocumentDeleteTest {
|
|||||||
JourneyItem.builder().geschichte(journey2).position(10).document(doc).note("Begleittext").build());
|
JourneyItem.builder().geschichte(journey2).position(10).document(doc).note("Begleittext").build());
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
documentService.deleteDocument(doc.getId());
|
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
assertThat(journeyItemRepository.findById(noteLess.getId())).isEmpty();
|
assertThat(journeyItemRepository.findById(noteLess.getId())).isEmpty();
|
||||||
@@ -183,7 +183,7 @@ class JourneyItemDocumentDeleteTest {
|
|||||||
doThrow(new RuntimeException("simulated failure"))
|
doThrow(new RuntimeException("simulated failure"))
|
||||||
.when(documentRepository).deleteById(any());
|
.when(documentRepository).deleteById(any());
|
||||||
|
|
||||||
assertThatThrownBy(() -> documentService.deleteDocument(doc.getId()))
|
assertThatThrownBy(() -> documentService.deleteDocument(doc.getId(), writer.getId()))
|
||||||
.isInstanceOf(RuntimeException.class);
|
.isInstanceOf(RuntimeException.class);
|
||||||
|
|
||||||
em.clear();
|
em.clear();
|
||||||
@@ -213,7 +213,7 @@ class JourneyItemDocumentDeleteTest {
|
|||||||
whitespaceNoteItemId, journey2.getId(), 20, doc.getId(), " ");
|
whitespaceNoteItemId, journey2.getId(), 20, doc.getId(), " ");
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
documentService.deleteDocument(doc.getId());
|
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
assertThat(journeyItemRepository.findById(emptyNoteItemId)).isEmpty();
|
assertThat(journeyItemRepository.findById(emptyNoteItemId)).isEmpty();
|
||||||
@@ -235,7 +235,7 @@ class JourneyItemDocumentDeleteTest {
|
|||||||
JourneyItem.builder().geschichte(journey).position(10).note("unrelated note").build());
|
JourneyItem.builder().geschichte(journey).position(10).note("unrelated note").build());
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
documentService.deleteDocument(unlinked.getId());
|
documentService.deleteDocument(unlinked.getId(), writer.getId());
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
assertThat(docRepo.findById(unlinked.getId())).isEmpty();
|
assertThat(docRepo.findById(unlinked.getId())).isEmpty();
|
||||||
@@ -251,9 +251,9 @@ class JourneyItemDocumentDeleteTest {
|
|||||||
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
|
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
documentService.deleteDocument(doc.getId());
|
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||||
|
|
||||||
verify(auditService).logAfterCommit(eq(AuditKind.DOCUMENT_DELETED), any(), eq(doc.getId()), any());
|
verify(auditService).logAfterCommit(eq(AuditKind.DOCUMENT_DELETED), eq(writer.getId()), eq(doc.getId()), any());
|
||||||
verify(auditService, never()).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_REMOVED), any(), any(), any());
|
verify(auditService, never()).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_REMOVED), any(), any(), any());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ class JourneyItemIntegrationTest {
|
|||||||
|
|
||||||
// Route through service so the DocumentDeletingEvent fires and the listener
|
// Route through service so the DocumentDeletingEvent fires and the listener
|
||||||
// removes note-less items before ON DELETE SET NULL acts on note-carrying rows.
|
// removes note-less items before ON DELETE SET NULL acts on note-carrying rows.
|
||||||
documentService.deleteDocument(doc.getId());
|
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||||
em.flush();
|
em.flush();
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
@@ -359,7 +359,7 @@ class JourneyItemIntegrationTest {
|
|||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
// Route through service so the DocumentDeletingEvent fires (V72 cascade fix).
|
// Route through service so the DocumentDeletingEvent fires (V72 cascade fix).
|
||||||
documentService.deleteDocument(doc.getId());
|
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||||
em.flush();
|
em.flush();
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user