Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
<version>3.1.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,14 @@
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.multipart.MultipartFile;

import org.apache.tika.Tika;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;

Expand All @@ -35,6 +40,16 @@ public class DocumentService {
@Value("${app.s3.bucket}")
private String bucket;

private static final Tika TIKA = new Tika();
private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
private static final Map<String, String> EXTENSION_TO_MIME = Map.of(
"pdf", "application/pdf",
"png", "image/png",
"jpg", "image/jpeg",
"jpeg", "image/jpeg",
"docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
);

@Transactional
public DocumentDTO uploadDocument(Actor actor, UUID caseId, MultipartFile file) throws IOException {
if (file == null || file.isEmpty()) {
Expand All @@ -49,6 +64,25 @@ public DocumentDTO uploadDocument(Actor actor, UUID caseId, MultipartFile file)
throw new BadRequestException("Content type is required");
}

if (file.getSize() > MAX_FILE_SIZE) {
throw new BadRequestException("File size exceeds the maximum allowed size of 10 MB");
}

String extension = originalFilename.contains(".")
? originalFilename.substring(originalFilename.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT)
: "";
String expectedMime = EXTENSION_TO_MIME.get(extension);

if (expectedMime == null || !expectedMime.equalsIgnoreCase(contentType)) {
throw new BadRequestException("File type not allowed. Allowed types: pdf, png, jpg, jpeg, docx");
}

byte[] fileBytes = file.getBytes();
String detectedMime = TIKA.detect(fileBytes, originalFilename);
if (!expectedMime.equalsIgnoreCase(detectedMime)) {
throw new BadRequestException("File content does not match declared type");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

CaseEntity caseEntity = caseRepository.findById(caseId)
.orElseThrow(() -> new BadRequestException("Case not found"));

Expand All @@ -58,13 +92,13 @@ public DocumentDTO uploadDocument(Actor actor, UUID caseId, MultipartFile file)

validateAccess(actor, caseEntity);

String s3Key = UUID.randomUUID().toString() + "-" + originalFilename;
String s3Key = UUID.randomUUID() + "-" + originalFilename;

try {
ObjectMetadata metadata = ObjectMetadata.builder()
.contentType(file.getContentType())
.build();
s3Template.upload(bucket, s3Key, file.getInputStream(), metadata);
s3Template.upload(bucket, s3Key, new ByteArrayInputStream(fileBytes), metadata);
} catch (Exception e) {
log.error("S3 upload failed for bucket: {}, key: {}. Error: {}", bucket, s3Key, e.getMessage(), e);
throw new AppException("S3_UPLOAD_FAILED", "Failed to upload file to S3");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ void setUp() {
@Test
@WithMockUser(username = "manager", roles = {"MANAGER"})
void uploadDocument_shouldSucceed() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "test.txt", MediaType.TEXT_PLAIN_VALUE, "hello".getBytes());
MockMultipartFile file = new MockMultipartFile("file", "test.pdf", "application/pdf", "%PDF-1.4 test".getBytes());

mockMvc.perform(multipart("/ui/cases/{caseId}/documents/upload", caseId)
.file(file)
Expand All @@ -112,7 +112,7 @@ void uploadDocument_shouldSucceed() throws Exception {
@Test
@WithMockUser(username = "manager", roles = {"MANAGER"})
void listDocuments_shouldReturnDocumentsWithoutLazyLoadingException() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "test.txt", MediaType.TEXT_PLAIN_VALUE, "hello".getBytes());
MockMultipartFile file = new MockMultipartFile("file", "test.pdf", "application/pdf", "%PDF-1.4 test".getBytes());
mockMvc.perform(multipart("/ui/cases/{caseId}/documents/upload", caseId)
.file(file).with(csrf()))
.andExpect(status().is3xxRedirection());
Expand All @@ -124,7 +124,7 @@ void listDocuments_shouldReturnDocumentsWithoutLazyLoadingException() throws Exc
@Test
@WithMockUser(username = "manager", roles = {"MANAGER"})
void downloadDocument_shouldSucceedWithoutLazyLoadingException() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "test.txt", MediaType.TEXT_PLAIN_VALUE, "hello".getBytes());
MockMultipartFile file = new MockMultipartFile("file", "test.pdf", "application/pdf", "%PDF-1.4 test".getBytes());
mockMvc.perform(multipart("/ui/cases/{caseId}/documents/upload", caseId)
.file(file).with(csrf()))
.andExpect(status().is3xxRedirection());
Expand All @@ -143,7 +143,7 @@ void downloadDocument_shouldSucceedWithoutLazyLoadingException() throws Exceptio
@Test
@WithMockUser(username = "other", roles = {"DOCTOR"})
void uploadDocument_shouldDenyUnauthorized() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "test.txt", MediaType.TEXT_PLAIN_VALUE, "hello".getBytes());
MockMultipartFile file = new MockMultipartFile("file", "test.pdf", "application/pdf", "%PDF-1.4 test".getBytes());

mockMvc.perform(multipart("/ui/cases/{caseId}/documents/upload", caseId)
.file(file)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@

import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
Expand Down Expand Up @@ -80,33 +82,33 @@ void setUp() {

@Test
void uploadDocument_shouldAllowOwner() throws IOException {
MockMultipartFile file = new MockMultipartFile("file", "test.txt", "text/plain", "hello".getBytes());
MockMultipartFile file = new MockMultipartFile("file", "test.pdf", "application/pdf", "%PDF-1.4 test".getBytes());
when(caseRepository.findById(caseId)).thenReturn(Optional.of(caseEntity));
when(documentRepository.save(any(DocumentEntity.class))).thenAnswer(i -> {
DocumentEntity e = i.getArgument(0);
e.setId(UUID.randomUUID());
return e;
});
when(documentMapper.toDTO(any(DocumentEntity.class))).thenReturn(new DocumentDTO(UUID.randomUUID(), "test.txt", "text/plain", 5, Instant.now(), doctorActor.userId(), caseId));
when(documentMapper.toDTO(any(DocumentEntity.class))).thenReturn(new DocumentDTO(UUID.randomUUID(), "test.pdf", "application/pdf", 5, Instant.now(), doctorActor.userId(), caseId));

DocumentDTO result = documentService.uploadDocument(doctorActor, caseId, file);

assertThat(result).isNotNull();
assertThat(result.fileName()).isEqualTo("test.txt");
assertThat(result.fileName()).isEqualTo("test.pdf");
verify(s3Template).upload(eq("test-bucket"), anyString(), any(InputStream.class), any(ObjectMetadata.class));
verify(documentRepository).save(any(DocumentEntity.class));
}

@Test
void uploadDocument_shouldAllowManager() throws IOException {
MockMultipartFile file = new MockMultipartFile("file", "test.txt", "text/plain", "hello".getBytes());
MockMultipartFile file = new MockMultipartFile("file", "test.pdf", "application/pdf", "%PDF-1.4 test".getBytes());
when(caseRepository.findById(caseId)).thenReturn(Optional.of(caseEntity));
when(documentRepository.save(any(DocumentEntity.class))).thenAnswer(i -> {
DocumentEntity e = i.getArgument(0);
e.setId(UUID.randomUUID());
return e;
});
when(documentMapper.toDTO(any(DocumentEntity.class))).thenReturn(new DocumentDTO(UUID.randomUUID(), "test.txt", "text/plain", 5, Instant.now(), managerActor.userId(), caseId));
when(documentMapper.toDTO(any(DocumentEntity.class))).thenReturn(new DocumentDTO(UUID.randomUUID(), "test.pdf", "application/pdf", 5, Instant.now(), managerActor.userId(), caseId));

DocumentDTO result = documentService.uploadDocument(managerActor, caseId, file);

Expand All @@ -117,13 +119,34 @@ void uploadDocument_shouldAllowManager() throws IOException {
@Test
void uploadDocument_shouldDenyUnauthorized() {
Actor unauthorizedActor = new Actor(UUID.randomUUID(), Role.DOCTOR, "Unauthorized", "unauthorized_user");
MockMultipartFile file = new MockMultipartFile("file", "test.txt", "text/plain", "hello".getBytes());
MockMultipartFile file = new MockMultipartFile("file", "test.pdf", "application/pdf", "%PDF-1.4 test".getBytes());
when(caseRepository.findById(caseId)).thenReturn(Optional.of(caseEntity));

assertThatThrownBy(() -> documentService.uploadDocument(unauthorizedActor, caseId, file))
.isInstanceOf(NotAuthorizedException.class);
}

@Test
void uploadDocument_shouldAllowDocx() throws IOException {
byte[] docxBytes = Files.readAllBytes(Path.of("src/test/resources/test.docx"));
String docxMime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
MockMultipartFile file = new MockMultipartFile("file", "test.docx", docxMime, docxBytes);
when(caseRepository.findById(caseId)).thenReturn(Optional.of(caseEntity));
when(documentRepository.save(any(DocumentEntity.class))).thenAnswer(i -> {
DocumentEntity e = i.getArgument(0);
e.setId(UUID.randomUUID());
return e;
});
when(documentMapper.toDTO(any(DocumentEntity.class))).thenReturn(
new DocumentDTO(UUID.randomUUID(), "test.docx", docxMime, docxBytes.length, Instant.now(), doctorActor.userId(), caseId));

DocumentDTO result = documentService.uploadDocument(doctorActor, caseId, file);

assertThat(result).isNotNull();
assertThat(result.fileName()).isEqualTo("test.docx");
verify(s3Template).upload(eq("test-bucket"), anyString(), any(InputStream.class), any(ObjectMetadata.class));
}

@Test
void deleteDocument_shouldAllowOwner() {
UUID docId = UUID.randomUUID();
Expand All @@ -142,15 +165,15 @@ void deleteDocument_shouldAllowOwner() {

@Test
void uploadDocument_shouldAllowPatientOnOwnCase() throws IOException {
MockMultipartFile file = new MockMultipartFile("file", "test.txt", "text/plain", "hello".getBytes());
MockMultipartFile file = new MockMultipartFile("file", "test.pdf", "application/pdf", "%PDF-1.4 test".getBytes());
when(caseRepository.findById(caseId)).thenReturn(Optional.of(caseEntity));
when(documentRepository.save(any(DocumentEntity.class))).thenAnswer(i -> {
DocumentEntity e = i.getArgument(0);
e.setId(UUID.randomUUID());
return e;
});
when(documentMapper.toDTO(any(DocumentEntity.class))).thenReturn(
new DocumentDTO(UUID.randomUUID(), "test.txt", "text/plain", 5, Instant.now(), patientActor.userId(), caseId));
new DocumentDTO(UUID.randomUUID(), "file", "test.pdf", 5, Instant.now(), patientActor.userId(), caseId));

DocumentDTO result = documentService.uploadDocument(patientActor, caseId, file);

Expand All @@ -161,7 +184,7 @@ void uploadDocument_shouldAllowPatientOnOwnCase() throws IOException {
@Test
void uploadDocument_shouldDenyPatientOnOtherCase() {
Actor otherPatient = new Actor(UUID.randomUUID(), Role.PATIENT, "Other", "other_patient");
MockMultipartFile file = new MockMultipartFile("file", "test.txt", "text/plain", "hello".getBytes());
MockMultipartFile file = new MockMultipartFile("file", "test.pdf", "application/pdf", "%PDF-1.4 test".getBytes());
when(caseRepository.findById(caseId)).thenReturn(Optional.of(caseEntity));

assertThatThrownBy(() -> documentService.uploadDocument(otherPatient, caseId, file))
Expand Down
Binary file added src/test/resources/test.docx
Binary file not shown.