fix(backend): move IOException into service, add content-type whitelist to attachFile

- DocumentService.attachFile() now catches IOException internally and
  re-throws as DomainException.internal — the IOException no longer leaks
  through the service boundary
- DocumentController.attachFile() is now a plain delegate (no try/catch)
- ALLOWED_CONTENT_TYPES whitelist (PDF/JPEG/PNG/TIFF) is now enforced on
  the attachFile endpoint, matching the existing quick-upload validation
- Added 5 DocumentService unit tests for attachFile (notFound, status
  transition PLACEHOLDER→UPLOADED, no-change when already UPLOADED,
  field assignment from upload result, IOException→DomainException)
- Added controller tests: 400 on disallowed content type, 404 on missing doc

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-18 14:27:22 +02:00
parent 836d30e262
commit 270005e0da
4 changed files with 114 additions and 9 deletions

View File

@@ -131,23 +131,23 @@ public class DocumentController {
// --- ATTACH FILE ---
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
"application/pdf", "image/jpeg", "image/png", "image/tiff");
@PostMapping(value = "/{id}/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RequirePermission(Permission.WRITE_ALL)
public Document attachFile(
@PathVariable UUID id,
@RequestPart("file") MultipartFile file) {
try {
return documentService.attachFile(id, file);
} catch (IOException e) {
throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "Failed to upload file: " + e.getMessage());
String contentType = file.getContentType();
if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported file type: " + contentType);
}
return documentService.attachFile(id, file);
}
// --- QUICK UPLOAD ---
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
"application/pdf", "image/jpeg", "image/png", "image/tiff");
public record UploadError(String filename, String code) {}
public record QuickUploadResult(List<Document> created, List<Document> updated, List<UploadError> errors) {}

View File

@@ -287,10 +287,15 @@ public class DocumentService {
}
@Transactional
public Document attachFile(UUID id, MultipartFile file) throws IOException {
public Document attachFile(UUID id, MultipartFile file) {
Document doc = documentRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
FileService.UploadResult upload = fileService.uploadFile(file, file.getOriginalFilename());
FileService.UploadResult upload;
try {
upload = fileService.uploadFile(file, file.getOriginalFilename());
} catch (IOException e) {
throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "Failed to upload file: " + e.getMessage());
}
doc.setFilePath(upload.s3Key());
doc.setFileHash(upload.fileHash());
doc.setOriginalFilename(file.getOriginalFilename());

View File

@@ -4,6 +4,8 @@ import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.DocumentVersion;
@@ -593,6 +595,33 @@ class DocumentControllerTest {
.andExpect(jsonPath("$.status").value("UPLOADED"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void attachFile_returns400_whenContentTypeIsNotWhitelisted() throws Exception {
org.springframework.mock.web.MockMultipartFile htmlFile =
new org.springframework.mock.web.MockMultipartFile(
"file", "evil.html", "text/html", "<script>alert(1)</script>".getBytes());
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(htmlFile))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void attachFile_returns404_whenDocumentDoesNotExist() throws Exception {
UUID id = UUID.randomUUID();
when(documentService.attachFile(eq(id), any()))
.thenThrow(DomainException.notFound(
ErrorCode.DOCUMENT_NOT_FOUND,
"Document not found: " + id));
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file))
.andExpect(status().isNotFound());
}
// ─── GET /api/documents/{id}/versions/{versionId} ────────────────────────
@Test

View File

@@ -1428,4 +1428,75 @@ class DocumentServiceTest {
new MatchOffset(10, 4) // "Welt"
);
}
// ─── attachFile ───────────────────────────────────────────────────────────
@Test
void attachFile_throwsNotFound_whenDocumentDoesNotExist() {
UUID id = UUID.randomUUID();
when(documentRepository.findById(id)).thenReturn(Optional.empty());
MockMultipartFile file = new MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
assertThatThrownBy(() -> documentService.attachFile(id, file))
.isInstanceOf(DomainException.class)
.hasMessageContaining(id.toString());
}
@Test
void attachFile_setsStatusToUploaded_whenDocumentIsPlaceholder() throws Exception {
UUID id = UUID.randomUUID();
Document placeholder = Document.builder().id(id).status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.findById(id)).thenReturn(Optional.of(placeholder));
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("s3/key.pdf", "abc123"));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
MockMultipartFile file = new MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
Document result = documentService.attachFile(id, file);
assertThat(result.getStatus()).isEqualTo(DocumentStatus.UPLOADED);
}
@Test
void attachFile_doesNotChangeStatus_whenAlreadyUploaded() throws Exception {
UUID id = UUID.randomUUID();
Document uploaded = Document.builder().id(id).status(DocumentStatus.UPLOADED).build();
when(documentRepository.findById(id)).thenReturn(Optional.of(uploaded));
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("s3/key.pdf", "abc123"));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
MockMultipartFile file = new MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
Document result = documentService.attachFile(id, file);
assertThat(result.getStatus()).isEqualTo(DocumentStatus.UPLOADED);
}
@Test
void attachFile_setsFilePathAndContentType_fromUploadResult() 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("s3/brief.pdf", "deadbeef"));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
MockMultipartFile file = new MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
Document result = documentService.attachFile(id, file);
assertThat(result.getFilePath()).isEqualTo("s3/brief.pdf");
assertThat(result.getFileHash()).isEqualTo("deadbeef");
assertThat(result.getContentType()).isEqualTo("application/pdf");
assertThat(result.getOriginalFilename()).isEqualTo("brief.pdf");
}
@Test
void attachFile_throwsDomainException_whenFileUploadFails() 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())).thenThrow(new java.io.IOException("storage unavailable"));
MockMultipartFile file = new MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
assertThatThrownBy(() -> documentService.attachFile(id, file))
.isInstanceOf(DomainException.class);
}
}