Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: P4ADEV-1830-save-file-to-shared-folder #9

Merged
merged 12 commits into from
Jan 15, 2025
1 change: 1 addition & 0 deletions openapi/p4pa-fileshare.openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,6 @@ components:
type: string
enum:
- INVALID_FILE
- FILE_UPLOAD_ERROR
message:
type: string
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import it.gov.pagopa.pu.fileshare.dto.generated.FileshareErrorDTO;
import it.gov.pagopa.pu.fileshare.dto.generated.FileshareErrorDTO.CodeEnum;
import it.gov.pagopa.pu.fileshare.exception.custom.FileUploadException;
import it.gov.pagopa.pu.fileshare.exception.custom.InvalidFileException;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -26,6 +27,11 @@ public ResponseEntity<FileshareErrorDTO> handleInvalidFileError(RuntimeException
return handleFileshareErrorException(ex, request, HttpStatus.BAD_REQUEST, CodeEnum.INVALID_FILE);
}

@ExceptionHandler({FileUploadException.class})
public ResponseEntity<FileshareErrorDTO> handleFileStorageError(RuntimeException ex, HttpServletRequest request){
return handleFileshareErrorException(ex, request, HttpStatus.INTERNAL_SERVER_ERROR, CodeEnum.FILE_UPLOAD_ERROR);
}

static ResponseEntity<FileshareErrorDTO> handleFileshareErrorException(RuntimeException ex, HttpServletRequest request, HttpStatus httpStatus, FileshareErrorDTO.CodeEnum errorEnum) {
String message = logException(ex, request, httpStatus);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package it.gov.pagopa.pu.fileshare.exception.custom;

public class FileUploadException extends RuntimeException {
public FileUploadException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,98 @@
package it.gov.pagopa.pu.fileshare.service;

import it.gov.pagopa.pu.fileshare.exception.custom.FileUploadException;
import it.gov.pagopa.pu.fileshare.exception.custom.InvalidFileException;
import it.gov.pagopa.pu.fileshare.util.AESUtils;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.stream.Stream;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

@Slf4j
@Service
public class FileService {
private final String sharedFolderRootPath;
private final String fileEncryptPassword;

public FileService(@Value("${folders.shared}") String sharedFolderRootPath,
@Value(("${app.fileEncryptPassword}")) String fileEncryptPassword) {
this.sharedFolderRootPath = sharedFolderRootPath;
this.fileEncryptPassword = fileEncryptPassword;
}

public void validateFile(MultipartFile ingestionFlowFile, String validFileExt) {
if( ingestionFlowFile == null || !StringUtils.defaultString(ingestionFlowFile.getOriginalFilename()).endsWith(validFileExt)){
if( ingestionFlowFile == null){
log.debug("Invalid ingestion flow file");
throw new InvalidFileException("Invalid file");
}
String filename = StringUtils.defaultString(ingestionFlowFile.getOriginalFilename());
validateFileExtension(validFileExt, filename);
validateFilename(filename);
}

private static void validateFilename(String filename) {
if(Stream.of("..", "\\", "/").anyMatch(filename::contains)){
log.debug("Invalid ingestion flow filename");
throw new InvalidFileException("Invalid filename");
}
}

private static void validateFileExtension(String validFileExt, String filename) {
if(!filename.endsWith(validFileExt)){
log.debug("Invalid ingestion flow file extension");
throw new InvalidFileException("Invalid file extension");
}
}

private Path getFilePath(String relativePath, String filename) {
String basePath = sharedFolderRootPath+relativePath;
Path fileLocation = Paths.get(basePath,filename).normalize();
if(!fileLocation.startsWith(basePath)){
log.debug("Invalid file path");
throw new InvalidFileException("Invalid file path");
}
return fileLocation;
}

public void saveToSharedFolder(MultipartFile file, String relativePath){
antonioT90 marked this conversation as resolved.
Show resolved Hide resolved
if(file==null){
log.debug("File is mandatory");
throw new FileUploadException("File is mandatory");
}

String filename = org.springframework.util.StringUtils.cleanPath(StringUtils.defaultString(file.getOriginalFilename()));
validateFilename(filename);
Path fileLocation = getFilePath(relativePath, filename);
//create missing parent folder, if any
try {
if (!fileLocation.toAbsolutePath().getParent().toFile().exists())
Fixed Show fixed Hide fixed
antonioT90 marked this conversation as resolved.
Show resolved Hide resolved
Files.createDirectories(fileLocation.toAbsolutePath().getParent());
Fixed Show fixed Hide fixed
antonioT90 marked this conversation as resolved.
Show resolved Hide resolved
encryptAndSaveFile(file, fileLocation);
}catch (Exception e) {
log.debug(
"Error uploading file to folder %s%s".formatted(sharedFolderRootPath,
relativePath), e);
throw new FileUploadException(
"Error uploading file to folder %s%s [%s]".formatted(
sharedFolderRootPath, relativePath, e.getMessage()));
antonioT90 marked this conversation as resolved.
Show resolved Hide resolved
}
log.debug("File upload to folder %s%s completed".formatted(sharedFolderRootPath,
relativePath));
}

private void encryptAndSaveFile(MultipartFile file, Path fileLocation)
throws IOException {
try(InputStream is = file.getInputStream();
InputStream cipherIs = AESUtils.encrypt(fileEncryptPassword, is)){
Files.copy(cipherIs, fileLocation, StandardCopyOption.REPLACE_EXISTING);
Fixed Show fixed Hide fixed
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,23 @@ public class IngestionFlowFileServiceImpl implements IngestionFlowFileService {
private final UserAuthorizationService userAuthorizationService;
private final FileService fileService;
private final String validIngestionFlowFileExt;
private final String ingestionFlowFilePath;

public IngestionFlowFileServiceImpl(
UserAuthorizationService userAuthorizationService, FileService fileService,
@Value("${uploads.ingestion-flow-file.valid-extension}") String validIngestionFlowFileExt) {
@Value("${uploads.ingestion-flow-file.valid-extension}") String validIngestionFlowFileExt,
@Value("${folders.ingestion-flow-file.path}") String ingestionFlowFilePath
) {
this.userAuthorizationService = userAuthorizationService;
this.fileService = fileService;
this.validIngestionFlowFileExt = validIngestionFlowFileExt;
this.ingestionFlowFilePath = ingestionFlowFilePath;
antonioT90 marked this conversation as resolved.
Show resolved Hide resolved
}

@Override
public void uploadIngestionFlowFile(Long organizationId, IngestionFlowFileType ingestionFlowFileType, MultipartFile ingestionFlowFile, UserInfo user, String accessToken) {
userAuthorizationService.checkUserAuthorization(organizationId, user, accessToken);
fileService.validateFile(ingestionFlowFile, validIngestionFlowFileExt);
fileService.saveToSharedFolder(ingestionFlowFile,ingestionFlowFilePath);
}
}
5 changes: 5 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ folders:
process-target-sub-folders:
archive: "\${PROCESS_TARGET_SUB_FOLDER_ARCHIVE:archive}"
errors: "\${PROCESS_TARGET_SUB_FOLDER_ERRORS:errors}"
ingestion-flow-file:
path: "\${INGESTION_FLOW_FILE_PATH:/ingestion_flow_file}"

rest:
default-timeout:
Expand All @@ -64,3 +66,6 @@ rest:
uploads:
ingestion-flow-file:
valid-extension: "\${INGESTION_FLOW_FILE_VALID_EXTENSION:.zip}"

app:
fileEncryptPassword: "\${FILE_ENCRYPT_PASSWORD:ENCR_PSW}"
antonioT90 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import static org.mockito.Mockito.doThrow;

import it.gov.pagopa.pu.fileshare.dto.generated.FileshareErrorDTO.CodeEnum;
import it.gov.pagopa.pu.fileshare.exception.custom.FileUploadException;
import it.gov.pagopa.pu.fileshare.exception.custom.InvalidFileException;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -58,4 +59,17 @@ void handleInvalidFileException() throws Exception {
.andExpect(MockMvcResultMatchers.jsonPath("$.code").value(CodeEnum.INVALID_FILE.toString()))
.andExpect(MockMvcResultMatchers.jsonPath("$.message").value("Error"));
}

@Test
void handleFileUploadException() throws Exception {
doThrow(new FileUploadException("Error")).when(testControllerSpy).testEndpoint(DATA);

mockMvc.perform(MockMvcRequestBuilders.get("/test")
.param(DATA, DATA)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON))
.andExpect(MockMvcResultMatchers.status().isInternalServerError())
.andExpect(MockMvcResultMatchers.jsonPath("$.code").value(CodeEnum.FILE_UPLOAD_ERROR.toString()))
.andExpect(MockMvcResultMatchers.jsonPath("$.message").value("Error"));
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
package it.gov.pagopa.pu.fileshare.service;

import it.gov.pagopa.pu.fileshare.exception.custom.FileUploadException;
import it.gov.pagopa.pu.fileshare.exception.custom.InvalidFileException;
import it.gov.pagopa.pu.fileshare.util.AESUtils;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
Expand All @@ -13,10 +20,12 @@
class FileServiceTest {
private FileService fileService;
private static final String VALID_FILE_EXTENSION = ".zip";
private static final String SHARED_FOLDER_ROOT_PATH = "/shared";
private static final String FILE_ENCRYPT_PASSWORD = "testPassword";

@BeforeEach
void setUp() {
fileService = new FileService();
fileService = new FileService(SHARED_FOLDER_ROOT_PATH,FILE_ENCRYPT_PASSWORD);
}

@Test
Expand All @@ -31,6 +40,16 @@ void givenValidFileExtensionWhenValidateFileThenOk(){
fileService.validateFile(file, VALID_FILE_EXTENSION);
}

@Test
void givenNoFileWhenValidateFileThenInvalidFileException(){
try{
fileService.validateFile(null, VALID_FILE_EXTENSION);
Assertions.fail("Expected InvalidFileException");
}catch(InvalidFileException e){
//do nothing
}
}

@Test
void givenInvalidFileExtensionWhenValidateFileThenInvalidFileException(){
MockMultipartFile file = new MockMultipartFile(
Expand All @@ -47,4 +66,139 @@ void givenInvalidFileExtensionWhenValidateFileThenInvalidFileException(){
//do nothing
}
}

@Test
void givenInvalidFilenameWhenValidateFileThenInvalidFileException(){
MockMultipartFile file = new MockMultipartFile(
"ingestionFlowFile",
"../test.zip",
MediaType.TEXT_PLAIN_VALUE,
"this is a test file".getBytes()
);

try{
fileService.validateFile(file, VALID_FILE_EXTENSION);
Assertions.fail("Expected InvalidFileException");
}catch(InvalidFileException e){
//do nothing
}
}

@Test
void givenInvalidFileWhenSaveToSharedFolderThenFileUploadException() {
try (MockedStatic<AESUtils> aesUtilsMockedStatic = Mockito.mockStatic(
AESUtils.class);
MockedStatic<Files> filesMockedStatic = Mockito.mockStatic(
Files.class)) {
try {
fileService.saveToSharedFolder(null, "");
Assertions.fail("Expected FileUploadException");
} catch (FileUploadException e) {
aesUtilsMockedStatic.verifyNoInteractions();
filesMockedStatic.verifyNoInteractions();
}
}
}

@Test
void givenInvalidFilenameWhenSaveToSharedFolderThenInvalidFileException() {
MockMultipartFile file = new MockMultipartFile(
"ingestionFlowFile",
"../test.txt",
MediaType.TEXT_PLAIN_VALUE,
"this is a test file".getBytes()
);

try (MockedStatic<AESUtils> aesUtilsMockedStatic = Mockito.mockStatic(
AESUtils.class);
MockedStatic<Files> filesMockedStatic = Mockito.mockStatic(
Files.class)) {
try {
fileService.saveToSharedFolder(file, "");
Assertions.fail("Expected InvalidFileException");
} catch (InvalidFileException e) {
aesUtilsMockedStatic.verifyNoInteractions();
filesMockedStatic.verifyNoInteractions();
}
}
}

@Test
void givenErrorWhenSaveToSharedFolderThenFileUploadException() {
MockMultipartFile file = new MockMultipartFile(
"ingestionFlowFile",
"test.txt",
MediaType.TEXT_PLAIN_VALUE,
"this is a test file".getBytes()
);

try (MockedStatic<AESUtils> aesUtilsMockedStatic = Mockito.mockStatic(
AESUtils.class);
MockedStatic<Files> filesMockedStatic = Mockito.mockStatic(
Files.class)) {
Mockito.when(AESUtils.encrypt(Mockito.eq(FILE_ENCRYPT_PASSWORD), (InputStream) Mockito.any()))
.thenThrow(new RuntimeException());

try {
fileService.saveToSharedFolder(file, "");
Assertions.fail("Expected FileUploadException");
} catch (FileUploadException e) {
aesUtilsMockedStatic.verify(() -> AESUtils.encrypt(Mockito.anyString(),(InputStream) Mockito.any()));
aesUtilsMockedStatic.verifyNoMoreInteractions();
filesMockedStatic.verify(() -> Files.createDirectories(Mockito.any()));
filesMockedStatic.verifyNoMoreInteractions();
}
}
}

@Test
void givenInvalidPathWhenSaveToSharedFolderThenInvalidFileException() {
MockMultipartFile file = new MockMultipartFile(
"ingestionFlowFile",
"test.txt",
MediaType.TEXT_PLAIN_VALUE,
"this is a test file".getBytes()
);

try (MockedStatic<AESUtils> aesUtilsMockedStatic = Mockito.mockStatic(
AESUtils.class);
MockedStatic<Files> filesMockedStatic = Mockito.mockStatic(
Files.class)) {

try {
fileService.saveToSharedFolder(file, "/../relative");
Assertions.fail("Expected InvalidFileException");
} catch (InvalidFileException e) {
aesUtilsMockedStatic.verifyNoInteractions();
filesMockedStatic.verifyNoInteractions();
}
}
}

@Test
void givenValidFileWhenSaveToSharedFolderThenOK() {
String filename = "test.txt";
MockMultipartFile file = new MockMultipartFile(
"ingestionFlowFile",
filename,
MediaType.TEXT_PLAIN_VALUE,
"this is a test file".getBytes()
);

try (MockedStatic<AESUtils> aesUtilsMockedStatic = Mockito.mockStatic(
AESUtils.class);
MockedStatic<Files> filesMockedStatic = Mockito.mockStatic(
Files.class)
) {
String relativePath = "/relative";
fileService.saveToSharedFolder(file, relativePath);

aesUtilsMockedStatic.verify(() -> AESUtils.encrypt(Mockito.anyString(),(InputStream) Mockito.any()));
aesUtilsMockedStatic.verifyNoMoreInteractions();
filesMockedStatic.verify(() -> Files.createDirectories(Mockito.eq(
Paths.get(SHARED_FOLDER_ROOT_PATH,relativePath))));
filesMockedStatic.verify(() -> Files.copy((InputStream) Mockito.any(),Mockito.eq(Paths.get(SHARED_FOLDER_ROOT_PATH,relativePath,filename)), Mockito.any()));
filesMockedStatic.verifyNoMoreInteractions();
}
}
}
Loading
Loading