feat(backend): hash uploaded files and store hash on documents and annotations

- Flyway V13: add file_hash column to documents and document_annotations
- FileService.uploadFile() now returns UploadResult(s3Key, fileHash) with SHA-256 hash computed from raw bytes
- Document and DocumentAnnotation models gain a fileHash field
- DocumentService propagates the hash at all three upload sites (storeDocument, createDocument, updateDocument)
- AnnotationService.createAnnotation() accepts and persists a fileHash
- AnnotationController resolves the document's hash and passes it through

Closes #55

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-24 17:08:55 +01:00
parent 34c66f80fc
commit 93f57477cd
11 changed files with 247 additions and 43 deletions

View File

@@ -4,10 +4,12 @@ import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.AnnotationService;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
@@ -36,6 +38,7 @@ class AnnotationControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean AnnotationService annotationService;
@MockitoBean DocumentService documentService;
@MockitoBean UserService userService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
@@ -85,7 +88,8 @@ class AnnotationControllerTest {
DocumentAnnotation saved = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
.x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build();
when(annotationService.createAnnotation(any(), any(), any())).thenReturn(saved);
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
.contentType(MediaType.APPLICATION_JSON)
@@ -97,7 +101,8 @@ class AnnotationControllerTest {
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void createAnnotation_returns409_whenOverlap() throws Exception {
when(annotationService.createAnnotation(any(), any(), any()))
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
when(annotationService.createAnnotation(any(), any(), any(), any()))
.thenThrow(DomainException.conflict(ErrorCode.ANNOTATION_OVERLAP, "Overlap"));
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")

View File

@@ -44,7 +44,7 @@ class AnnotationServiceTest {
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1))
.thenReturn(List.of(existing));
assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId))
assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId, null))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(CONFLICT));
@@ -63,7 +63,7 @@ class AnnotationServiceTest {
.x(0.0).y(0.0).width(0.05).height(0.05).color("#ff0000").createdBy(userId).build();
when(annotationRepository.save(any())).thenReturn(saved);
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId);
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, null);
assertThat(result).isEqualTo(saved);
verify(annotationRepository).save(any());
@@ -117,6 +117,35 @@ class AnnotationServiceTest {
verify(annotationRepository).delete(annotation);
}
@Test
void createAnnotation_setsFileHash_whenProvided() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
String fileHash = "abc123";
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, fileHash);
assertThat(result.getFileHash()).isEqualTo(fileHash);
}
@Test
void createAnnotation_setsNullFileHash_whenNoneProvided() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, null);
assertThat(result.getFileHash()).isNull();
}
// ─── listAnnotations ──────────────────────────────────────────────────────
@Test

View File

@@ -135,6 +135,48 @@ class DocumentServiceTest {
assertThat(documentService.getDocumentsByReceiver(receiverId)).containsExactly(doc);
}
// ─── file hash propagation ───────────────────────────────────────────────
@Test
void createDocument_setsFileHashFromUpload_whenFileProvided() throws Exception {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("Doc");
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan.pdf", "deadbeef");
Document savedDoc = Document.builder().id(UUID.randomUUID()).title("Doc")
.originalFilename("scan.pdf").status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.save(any())).thenReturn(savedDoc);
when(documentRepository.findById(any())).thenReturn(Optional.of(savedDoc));
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
documentService.createDocument(dto, file);
org.mockito.ArgumentCaptor<Document> captor = org.mockito.ArgumentCaptor.forClass(Document.class);
verify(documentRepository, atLeastOnce()).save(captor.capture());
assertThat(captor.getAllValues()).anySatisfy(d -> assertThat(d.getFileHash()).isEqualTo("deadbeef"));
}
@Test
void updateDocument_setsFileHashFromUpload_whenNewFileProvided() throws Exception {
UUID id = UUID.randomUUID();
Document existing = Document.builder()
.id(id).title("Alt").originalFilename("old.pdf")
.status(DocumentStatus.UPLOADED).build();
org.springframework.mock.web.MockMultipartFile newFile =
new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{2});
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_new.pdf", "cafebabe");
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
when(documentRepository.save(any())).thenReturn(existing);
documentService.updateDocument(id, new DocumentUpdateDTO(), newFile);
assertThat(existing.getFileHash()).isEqualTo("cafebabe");
}
// ─── versioning ───────────────────────────────────────────────────────────
@Test

View File

@@ -0,0 +1,85 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.mock.web.MockMultipartFile;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class FileServiceTest {
private S3Client s3Client;
private FileService fileService;
@BeforeEach
void setUp() {
s3Client = mock(S3Client.class);
fileService = new FileService(s3Client, "test-bucket");
}
@Test
void uploadFile_returnsS3Key() throws IOException {
MockMultipartFile file = new MockMultipartFile(
"file", "test.pdf", "application/pdf", new byte[]{1, 2, 3});
FileService.UploadResult result = fileService.uploadFile(file, "test.pdf");
assertThat(result.s3Key()).startsWith("documents/");
assertThat(result.s3Key()).endsWith("_test.pdf");
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
}
@Test
void uploadFile_returnsCorrectSha256FileHash() throws IOException, NoSuchAlgorithmException {
byte[] content = "hello pdf content".getBytes();
MockMultipartFile file = new MockMultipartFile(
"file", "doc.pdf", "application/pdf", content);
FileService.UploadResult result = fileService.uploadFile(file, "doc.pdf");
// Compute expected hash independently
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hashBytes = digest.digest(content);
StringBuilder expected = new StringBuilder();
for (byte b : hashBytes) {
expected.append(String.format("%02x", b));
}
assertThat(result.fileHash()).isEqualTo(expected.toString());
}
@Test
void uploadFile_differentContents_produceDifferentHashes() throws IOException {
MockMultipartFile file1 = new MockMultipartFile(
"f", "a.pdf", "application/pdf", new byte[]{1, 2, 3});
MockMultipartFile file2 = new MockMultipartFile(
"f", "b.pdf", "application/pdf", new byte[]{4, 5, 6});
FileService.UploadResult r1 = fileService.uploadFile(file1, "a.pdf");
FileService.UploadResult r2 = fileService.uploadFile(file2, "b.pdf");
assertThat(r1.fileHash()).isNotEqualTo(r2.fileHash());
}
@Test
void uploadFile_sameContents_produceSameHash() throws IOException {
byte[] content = new byte[]{10, 20, 30};
MockMultipartFile file1 = new MockMultipartFile("f", "x.pdf", "application/pdf", content);
MockMultipartFile file2 = new MockMultipartFile("f", "y.pdf", "application/pdf", content);
FileService.UploadResult r1 = fileService.uploadFile(file1, "x.pdf");
FileService.UploadResult r2 = fileService.uploadFile(file2, "y.pdf");
assertThat(r1.fileHash()).isEqualTo(r2.fileHash());
}
}