diff --git a/src/inttest/java/com/faforever/api/deployment/ExeUploadControllerTest.java b/src/inttest/java/com/faforever/api/deployment/ExeUploadControllerTest.java new file mode 100644 index 000000000..1424a12b7 --- /dev/null +++ b/src/inttest/java/com/faforever/api/deployment/ExeUploadControllerTest.java @@ -0,0 +1,90 @@ +package com.faforever.api.deployment; + +import com.faforever.api.AbstractIntegrationTest; +import org.junit.Before; +import org.junit.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.jdbc.Sql; + +import java.nio.file.Files; +import java.nio.file.Paths; + +import static org.junit.Assert.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.fileUpload; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Sql(executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, scripts = "classpath:sql/prepFeaturedMods.sql") +@Sql(executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD, scripts = "classpath:sql/cleanFeaturedMods.sql") +public class ExeUploadControllerTest extends AbstractIntegrationTest { + private MockMultipartFile file; + private static final String SUPER_SECRET = "banana"; + + @Before + public void setUp() { + super.setUp(); + file = new MockMultipartFile("file", "ForgedAlliance.exe", "application/octet-stream", new byte[]{ 1, 2 ,3, 4 }); + } + + @Test + public void testSuccessUploadBeta() throws Exception { + this.mockMvc.perform(fileUpload("/exe/upload") + .file(file) + .param("modName", "fafbeta") + .param("apiKey", SUPER_SECRET) + ).andExpect(status().isOk()); + assertTrue(Files.exists(Paths.get("build/exe/beta/ForgedAlliance.3706.exe"))); + } + + @Test + public void testSuccessUploadDevelop() throws Exception { + this.mockMvc.perform(fileUpload("/exe/upload") + .file(file) + .param("modName", "fafdevelop") + .param("apiKey", SUPER_SECRET) + ).andExpect(status().isOk()); + assertTrue(Files.exists(Paths.get("build/exe/develop/ForgedAlliance.3707.exe"))); + } + + @Test + public void testBadRequestUploadNoModName() throws Exception { + this.mockMvc.perform(fileUpload("/exe/upload") + .file(file) + .param("apiKey", SUPER_SECRET) + ).andExpect(status().is4xxClientError()); + } + + @Test + public void testBadRequestUploadNoFile() throws Exception { + this.mockMvc.perform(fileUpload("/exe/upload") + .param("modName", "fafdevelop") + .param("apiKey", SUPER_SECRET) + ).andExpect(status().is4xxClientError()); + } + + @Test + public void testBadRequestUploadFileWithWrongExeExtension() throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "ForgedAlliance.zip", "application/octet-stream", new byte[]{ 1, 2 ,3, 4 }); + this.mockMvc.perform(fileUpload("/exe/upload") + .file(file) + .param("modName", "fafbeta") + .param("apiKey", SUPER_SECRET) + ).andExpect(status().is4xxClientError()); + } + + @Test + public void testBadRequestUploadWithoutApiKey() throws Exception { + this.mockMvc.perform(fileUpload("/exe/upload") + .file(file) + .param("modName", "fafbeta") + ).andExpect(status().is4xxClientError()); + } + + @Test + public void testBadRequestUploadWithWrongApiKey() throws Exception { + this.mockMvc.perform(fileUpload("/exe/upload") + .file(file) + .param("modName", "fafbeta") + .param("apiKey", "not a banana") + ).andExpect(status().is4xxClientError()); + } +} diff --git a/src/inttest/resources/config/application.yml b/src/inttest/resources/config/application.yml index 2cb9f847c..923d677cf 100644 --- a/src/inttest/resources/config/application.yml +++ b/src/inttest/resources/config/application.yml @@ -66,6 +66,15 @@ faf-api: mautic: client-id: banana client-secret: banana + deployment: + featured-mods-target-directory: "build/cache/mods" + repositories-directory: "" + forged-alliance-exe-path: "build/exe/faf" + testing-exe-upload-key: banana + forged-alliance-beta-exe-path: build/exe/beta + forged-alliance-develop-exe-path: build/exe/develop + featured-mod: + file-url-format: "file://%s/%s" logging: diff --git a/src/inttest/resources/sql/cleanFeaturedMods.sql b/src/inttest/resources/sql/cleanFeaturedMods.sql new file mode 100644 index 000000000..97f1dbea2 --- /dev/null +++ b/src/inttest/resources/sql/cleanFeaturedMods.sql @@ -0,0 +1,5 @@ +DELETE FROM updates_fafdevelop_files; +DELETE FROM updates_fafbeta_files; +DELETE FROM updates_fafdevelop; +DELETE FROM updates_fafbeta; +DELETE FROM game_featuredMods; diff --git a/src/inttest/resources/sql/prepFeaturedMods.sql b/src/inttest/resources/sql/prepFeaturedMods.sql new file mode 100644 index 000000000..8bd0e368f --- /dev/null +++ b/src/inttest/resources/sql/prepFeaturedMods.sql @@ -0,0 +1,25 @@ +DELETE FROM updates_fafdevelop_files; +DELETE FROM updates_fafdevelop; +DELETE FROM updates_fafbeta_files; +DELETE FROM updates_fafbeta; +DELETE FROM reported_user; +DELETE FROM moderation_report; +DELETE FROM game_stats; +DELETE FROM game_featuredMods; + +INSERT INTO `game_featuredMods` (`id`, `gamemod`, `description`, `name`, `publish`, `order`, `git_url`, `git_branch`, `file_extension`, `allow_override`, `deployment_webhook`) VALUES +(1, 'faf', 'Forged Alliance Forever', 'FAF', 1, 0, 'https://github.com/FAForever/fa.git', 'deploy/faf', 'nx2', 0, NULL), +(27, 'fafbeta', 'Beta version of the next FAF patch', 'FAF Beta', 1, 2, 'https://github.com/FAForever/fa.git', 'deploy/fafbeta', 'nx4', 1, NULL), +(28, 'fafdevelop', 'Updated frequently for testing the upcoming game Patch', 'FAF Develop', 1, 11, 'https://github.com/FAForever/fa.git', 'deploy/fafdevelop', 'nx5', 1, NULL); + +INSERT INTO `updates_fafbeta` (`id`, `filename`, `path`) VALUES +(1, 'ForgedAlliance.exe', 'bin'); + +INSERT INTO `updates_fafbeta_files` (`id`, `fileId`, `version`, `name`, `md5`, `obselete`) VALUES +(1703, 1, 3706, 'ForgedAlliance.3706.exe', 'c20b922a785cf5876c39b7696a16f162', 0); + +INSERT INTO `updates_fafdevelop` (`id`, `filename`, `path`) VALUES +(1, 'ForgedAlliance.exe', 'bin'); + +INSERT INTO `updates_fafdevelop_files` (`id`, `fileId`, `version`, `name`, `md5`, `obselete`) VALUES +(4327, 1, 3707, 'ForgedAlliance.3707.exe', '79f0ea70625ab464d369721183e9fd29', 0); diff --git a/src/main/java/com/faforever/api/config/FafApiProperties.java b/src/main/java/com/faforever/api/config/FafApiProperties.java index 1a9969406..222456320 100644 --- a/src/main/java/com/faforever/api/config/FafApiProperties.java +++ b/src/main/java/com/faforever/api/config/FafApiProperties.java @@ -157,6 +157,10 @@ public static class Deployment { private String repositoriesDirectory; private String filesDirectoryFormat = "updates_%s_files"; private String forgedAllianceExePath; + private String testingExeUploadKey; + private String allowedExeExtension = "exe"; + private String forgedAllianceBetaExePath; + private String forgedAllianceDevelopExePath; } @Data diff --git a/src/main/java/com/faforever/api/config/swagger/SwaggerConfig.java b/src/main/java/com/faforever/api/config/swagger/SwaggerConfig.java index 1b85572b1..28eed5c44 100644 --- a/src/main/java/com/faforever/api/config/swagger/SwaggerConfig.java +++ b/src/main/java/com/faforever/api/config/swagger/SwaggerConfig.java @@ -60,6 +60,7 @@ private Predicate paths() { regex("/users/.*"), regex("/mods/.*"), regex("/maps/.*"), + regex("/exe/.*"), regex("/leaderboards/.*"), regex("/voting/.*")); } diff --git a/src/main/java/com/faforever/api/deployment/ExeUploaderController.java b/src/main/java/com/faforever/api/deployment/ExeUploaderController.java new file mode 100644 index 000000000..538316aac --- /dev/null +++ b/src/main/java/com/faforever/api/deployment/ExeUploaderController.java @@ -0,0 +1,59 @@ +package com.faforever.api.deployment; + +import com.faforever.api.config.FafApiProperties; +import com.faforever.api.error.ApiException; +import com.faforever.api.error.Error; +import com.faforever.api.error.ErrorCode; +import com.google.common.io.Files; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiResponse; +import io.swagger.annotations.ApiResponses; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import static org.springframework.http.MediaType.APPLICATION_JSON_UTF8_VALUE; + +@RestController +@RequestMapping(path = "/exe") +@Slf4j +public class ExeUploaderController { + private final FafApiProperties apiProperties; + private final ExeUploaderService exeUploaderService; + + public ExeUploaderController( + FafApiProperties apiProperties, + ExeUploaderService exeUploaderService + ) { + this.apiProperties = apiProperties; + this.exeUploaderService = exeUploaderService; + } + + @ApiOperation("Upload an exe file") + @ApiResponses(value = { + @ApiResponse(code = 200, message = "Success"), + @ApiResponse(code = 401, message = "Unauthorized"), + @ApiResponse(code = 500, message = "Failure")}) + @RequestMapping(path = "/upload", method = RequestMethod.POST, produces = APPLICATION_JSON_UTF8_VALUE) + public void upload(@RequestParam("file") MultipartFile file, + @RequestParam("modName") String modName, + @RequestParam("apiKey") String apiKey + ) throws Exception { + if (!apiKey.equals(apiProperties.getDeployment().getTestingExeUploadKey())) { + throw new ApiException(new Error(ErrorCode.API_KEY_INVALID)); + } + String extension = Files.getFileExtension(file.getOriginalFilename()); + if (!apiProperties.getDeployment().getAllowedExeExtension().equals(extension)) { + throw new ApiException( + new Error(ErrorCode.UPLOAD_INVALID_FILE_EXTENSIONS, apiProperties.getDeployment().getAllowedExeExtension()) + ); + } + + log.info("Uploading exe file '{}' to '{}' directory", file.getOriginalFilename(), modName); + + exeUploaderService.processUpload(file.getInputStream(), modName); + } +} diff --git a/src/main/java/com/faforever/api/deployment/ExeUploaderService.java b/src/main/java/com/faforever/api/deployment/ExeUploaderService.java new file mode 100644 index 000000000..0496c920b --- /dev/null +++ b/src/main/java/com/faforever/api/deployment/ExeUploaderService.java @@ -0,0 +1,101 @@ +package com.faforever.api.deployment; + +import com.faforever.api.config.FafApiProperties; +import com.faforever.api.content.ContentService; +import com.faforever.api.error.ApiException; +import com.faforever.api.error.Error; +import com.faforever.api.error.ErrorCode; +import com.faforever.api.featuredmods.FeaturedModFile; +import com.faforever.api.featuredmods.FeaturedModService; +import com.faforever.api.utils.FilePermissionUtil; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.Assert; + +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.Collections; +import java.util.List; + +import static com.google.common.hash.Hashing.md5; +import static com.google.common.io.Files.asByteSource; +import static java.nio.file.Files.createDirectories; + +@Service +@Slf4j +public class ExeUploaderService { + private final ContentService contentService; + private final FafApiProperties apiProperties; + private final FeaturedModService featuredModService; + + public ExeUploaderService( + ContentService contentService, + FafApiProperties apiProperties, + FeaturedModService featuredModService + ) { + this.contentService = contentService; + this.apiProperties = apiProperties; + this.featuredModService = featuredModService; + } + + @Transactional + @SneakyThrows + public void processUpload(InputStream exeDataInputStream, String modName) { + checkAllowedBranchName(modName); + FeaturedModFile featuredModFile = featuredModService.getFile(modName, null, "ForgedAlliance.exe"); + featuredModFile.setName(String.format("ForgedAlliance.%d.exe", featuredModFile.getVersion())); + Path uploadedFile = this.upload( + exeDataInputStream, + featuredModFile.getName(), + modName + ); + featuredModFile.setMd5(asByteSource(uploadedFile.toFile()).hash(md5()).toString()); + exeDataInputStream.close(); + List featuredModFiles = Collections.singletonList(featuredModFile); + featuredModService.save(modName, (short) featuredModFile.getVersion(), featuredModFiles); + } + + private Path upload(InputStream exeDataInputStream, String fileName, String modName) throws IOException { + Assert.isTrue(exeDataInputStream.available() > 0, "data of 'ForgedAlliance.exe' must not be empty"); + + Path tempDir = contentService.createTempDir(); + Path temporaryFile = tempDir.resolve(fileName); + Files.copy(exeDataInputStream, temporaryFile); + + Path copyTo = getCopyToPath(modName, fileName); + createDirectories(copyTo.getParent(), FilePermissionUtil.directoryPermissionFileAttributes()); + return Files.copy( + temporaryFile, + copyTo, + StandardCopyOption.REPLACE_EXISTING + ); + } + + private void checkAllowedBranchName(String modName) throws ApiException { + if (!"fafbeta".equals(modName) && !"fafdevelop".equals(modName)) { + throw new ApiException(new Error(ErrorCode.INVALID_FEATURED_MOD, modName)); + } + } + + private Path getCopyToPath(String modName, String fileName) { + String copyTo = null; + switch (modName) { + case "fafbeta": + copyTo = apiProperties.getDeployment().getForgedAllianceBetaExePath(); + break; + case "fafdevelop": + copyTo = apiProperties.getDeployment().getForgedAllianceDevelopExePath(); + break; + default: + throw new ApiException(new Error(ErrorCode.INVALID_FEATURED_MOD, modName)); + } + + return Paths.get(copyTo, fileName); + } +} diff --git a/src/main/java/com/faforever/api/deployment/LegacyFeaturedModDeploymentTask.java b/src/main/java/com/faforever/api/deployment/LegacyFeaturedModDeploymentTask.java index 145793151..18e10c5d0 100644 --- a/src/main/java/com/faforever/api/deployment/LegacyFeaturedModDeploymentTask.java +++ b/src/main/java/com/faforever/api/deployment/LegacyFeaturedModDeploymentTask.java @@ -29,18 +29,9 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; -import java.nio.file.FileVisitResult; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.SimpleFileVisitor; -import java.nio.file.StandardCopyOption; +import java.nio.file.*; import java.nio.file.attribute.BasicFileAttributes; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.OptionalInt; +import java.util.*; import java.util.function.Consumer; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -108,7 +99,10 @@ public void run() { Deployment deployment = apiProperties.getDeployment(); Path targetFolder = Paths.get(deployment.getFeaturedModsTargetDirectory(), String.format(deployment.getFilesDirectoryFormat(), modName)); List files = packageDirectories(repositoryDirectory, version, fileIds, targetFolder); - createPatchedExe(version, fileIds, targetFolder).ifPresent(files::add); + + if ("faf".equals(modName)) { + createPatchedExe(version, fileIds, targetFolder).ifPresent(files::add); + } if (files.isEmpty()) { log.warn("Could not find any files to deploy. Is the configuration correct?"); @@ -161,7 +155,7 @@ private Optional createPatchedExe(short version, Map } private short readModVersion(Path modPath) { - return (short) Integer.parseInt(new ModReader().readDirectory(modPath).getVersion().toString()); + return Short.parseShort(new ModReader().readDirectory(modPath).getVersion().toString()); } private void verifyVersion(int version, boolean allowOverride, String modName) { diff --git a/src/main/java/com/faforever/api/error/ErrorCode.java b/src/main/java/com/faforever/api/error/ErrorCode.java index 37a96e627..9cfe5c131 100644 --- a/src/main/java/com/faforever/api/error/ErrorCode.java +++ b/src/main/java/com/faforever/api/error/ErrorCode.java @@ -97,7 +97,9 @@ public enum ErrorCode { MAP_NAME_TOO_SHORT(187, "Map name invalid", "The map name must have at least {0, number} characters, was: {1, number}"), MAP_NAME_DOES_NOT_START_WITH_LETTER(188, "Map name invalid", "The map name has to begin with a letter"), PARSING_LUA_FILE_FAILED(189, "Parsing lua files failed", "During the parsing of the lua file an error occured: {0}"), - NO_RUSH_RADIUS_MISSING(190, "No rush radius missing", "The scenario file must specify a no rush radius"); + NO_RUSH_RADIUS_MISSING(190, "No rush radius missing", "The scenario file must specify a no rush radius"), + INVALID_FEATURED_MOD(191, "Invalid featured mod name", "The featured mod name ''{0}'' is not allowed in this context."), + API_KEY_INVALID(192, "Api key is invalid", "The api key is invalid."); private final int code; private final String title; diff --git a/src/main/java/com/faforever/api/featuredmods/FeaturedModService.java b/src/main/java/com/faforever/api/featuredmods/FeaturedModService.java index 0ef43283e..e39188b4e 100644 --- a/src/main/java/com/faforever/api/featuredmods/FeaturedModService.java +++ b/src/main/java/com/faforever/api/featuredmods/FeaturedModService.java @@ -22,6 +22,10 @@ public FeaturedModService(FeaturedModRepository featuredModRepository, LegacyFea this.legacyFeaturedModFileRepository = legacyFeaturedModFileRepository; } + public FeaturedModFile getFile(String modName, Integer version, String fileName) { + return legacyFeaturedModFileRepository.getFile(modName, version, fileName); + } + @Cacheable(FEATURED_MOD_FILES_CACHE_NAME) public List getFiles(String modName, @Nullable Integer version) { return legacyFeaturedModFileRepository.getFiles(modName, version); diff --git a/src/main/java/com/faforever/api/featuredmods/LegacyFeaturedModFileRepository.java b/src/main/java/com/faforever/api/featuredmods/LegacyFeaturedModFileRepository.java index b168fd40d..9c5a20f7f 100644 --- a/src/main/java/com/faforever/api/featuredmods/LegacyFeaturedModFileRepository.java +++ b/src/main/java/com/faforever/api/featuredmods/LegacyFeaturedModFileRepository.java @@ -19,9 +19,21 @@ public LegacyFeaturedModFileRepository(EntityManager entityManager) { this.entityManager = entityManager; } - @SuppressWarnings("unchecked") + public FeaturedModFile getFile(String modName, Integer version, String fileName) { + List files = getFiles(modName, version, fileName); + Assert.isTrue( + files.size() == 1, + String.format("Unexpected result size %d for modName: %s, version: %d, filename: %s", files.size(), modName, version, fileName) + ); + return files.get(0); + } + public List getFiles(String modName, Integer version) { - // Please shoot me. + return getFiles(modName, version, null); + } + + @SuppressWarnings("unchecked") + public List getFiles(String modName, Integer version, String fileName) { String innerModName = "ladder1v1".equals(modName) ? "faf" : modName; verifyModName(innerModName); @@ -35,7 +47,7 @@ public List getFiles(String modName, Integer version) { " file.fileId AS `fileId`," + " file.id AS `id`," + " file.name AS `fileName`," + - " 'updates_%1$s_files' AS `folderName`" + + " 'updates_%1$s_files' AS `folderName` " + "FROM updates_%1$s_files file" + " INNER JOIN" + " (" + @@ -49,11 +61,18 @@ public List getFiles(String modName, Integer version) { " ON file.fileId = latest.fileId" + " AND file.version = latest.version" + " INNER JOIN updates_%1$s b" + - " ON b.id = file.fileId;", innerModName, (version == null ? "" : "WHERE version <= :version")), FeaturedModFile.class); + " ON b.id = file.fileId %3$s ", + innerModName, + (version == null ? "" : "WHERE version <= :version"), + (fileName == null ? "" : " AND filename = :filename") + ), FeaturedModFile.class); if (version != null) { query.setParameter("version", version); } + if (fileName != null) { + query.setParameter("filename", fileName); + } return (List) query.getResultList(); } diff --git a/src/main/resources/config/application-dev.yml b/src/main/resources/config/application-dev.yml index c6ccaba5e..69ee5847c 100644 --- a/src/main/resources/config/application-dev.yml +++ b/src/main/resources/config/application-dev.yml @@ -29,6 +29,9 @@ faf-api: forged-alliance-exe-path: ${FORGED_ALLIANCE_EXE_PATH} repositories-directory: ${REPOSITORIES_DIRECTORY:build/cache/repos} featured-mods-target-directory: ${FEATURED_MODS_TARGET_DIRECTORY:build/cache/deployment} + testing-exe-upload-key: ${TESTING_EXE_UPLOAD_KEY:banana} + forged-alliance-beta-exe-path: ${EXE_UPLOAD_BETA_PATH:build/exe/beta} + forged-alliance-develop-exe-path: ${EXE_UPLOAD_DEVELOP_PATH:build/exe/develop} clan: website-url-format: ${CLAN_WEBSITE_URL_FORMAT:http://clans.test.faforever.com/clan/%s} mail: diff --git a/src/main/resources/config/application-prod.yml b/src/main/resources/config/application-prod.yml index dabd06f4e..3021be575 100644 --- a/src/main/resources/config/application-prod.yml +++ b/src/main/resources/config/application-prod.yml @@ -30,6 +30,9 @@ faf-api: forged-alliance-exe-path: ${FORGED_ALLIANCE_EXE_PATH} repositories-directory: ${REPOSITORIES_DIRECTORY} featured-mods-target-directory: ${FEATURED_MODS_TARGET_DIRECTORY} + testing-exe-upload-key: ${TESTING_EXE_UPLOAD_KEY} + forged-alliance-beta-exe-path: ${EXE_UPLOAD_BETA_PATH} + forged-alliance-develop-exe-path: ${EXE_UPLOAD_DEVELOP_PATH} clan: website-url-format: ${CLAN_WEBSITE_URL_FORMAT} mail: diff --git a/src/test/java/com/faforever/api/deployment/ExeUploaderControllerTest.java b/src/test/java/com/faforever/api/deployment/ExeUploaderControllerTest.java new file mode 100644 index 000000000..08ed23f4a --- /dev/null +++ b/src/test/java/com/faforever/api/deployment/ExeUploaderControllerTest.java @@ -0,0 +1,100 @@ +package com.faforever.api.deployment; + +import com.faforever.api.config.FafApiProperties; +import com.faforever.api.config.TestWebSecurityConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; + +import javax.inject.Inject; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.fileUpload; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(SpringExtension.class) +@WebMvcTest(ExeUploaderController.class) +@Import(TestWebSecurityConfig.class) +public class ExeUploaderControllerTest { + + public static final String API_KEY = "banana"; + private MockMvc mvc; + @MockBean + private ExeUploaderService exeUploaderService; + @MockBean + private FafApiProperties fafApiProperties; + @MockBean + private FafApiProperties.Deployment deployment; + + @Mock + private MockMultipartFile file; + + @Inject + public void init(MockMvc mvc) { + this.mvc = mvc; + } + + @BeforeEach + public void setUp() { + when(fafApiProperties.getDeployment()).thenReturn(deployment); + when(deployment.getAllowedExeExtension()).thenReturn("exe"); + when(deployment.getTestingExeUploadKey()).thenReturn(API_KEY); + file = new MockMultipartFile("file", + "ForgedAlliance.exe", + "application/octet-stream", + new byte[]{1, 2, 3, 4}); + } + + @Test + public void testSuccessUpload() throws Exception { + this.mvc.perform(fileUpload("/exe/upload") + .file(file) + .param("modName", "fafbeta") + .param("apiKey", API_KEY) + ).andExpect(status().isOk()); + } + + @Test + public void testBadRequestUploadNoModName() throws Exception { + this.mvc.perform(fileUpload("/exe/upload") + .file(file) + .param("apiKey", API_KEY) + ).andExpect(status().isBadRequest()); + } + + @Test + public void testBadRequestUploadNoFile() throws Exception { + this.mvc.perform(fileUpload("/exe/upload") + .param("modName", "fafbeta2222") + .param("apiKey", API_KEY) + ).andExpect(status().isBadRequest()); + } + + @Test + public void testBadRequestUploadFileWithWrongExeExtension() throws Exception { + MockMultipartFile file = new MockMultipartFile("file", + "ForgedAlliance.zip", + "application/octet-stream", + new byte[]{1, 2, 3, 4}); + this.mvc.perform(fileUpload("/exe/upload") + .file(file) + .param("modName", "fafbeta") + .param("apiKey", API_KEY) + ).andExpect(status().is4xxClientError()); + } + + @Test + public void testBadRequestUploadWithoutApiKey() throws Exception { + this.mvc.perform(fileUpload("/exe/upload") + .file(file) + .param("modName", "fafbeta") + ).andExpect(status().isBadRequest()); + } +} diff --git a/src/test/java/com/faforever/api/deployment/ExeUploaderServiceTest.java b/src/test/java/com/faforever/api/deployment/ExeUploaderServiceTest.java new file mode 100644 index 000000000..644e5062f --- /dev/null +++ b/src/test/java/com/faforever/api/deployment/ExeUploaderServiceTest.java @@ -0,0 +1,140 @@ +package com.faforever.api.deployment; + +import com.faforever.api.config.FafApiProperties; +import com.faforever.api.content.ContentService; +import com.faforever.api.error.ApiException; +import com.faforever.api.featuredmods.FeaturedModFile; +import com.faforever.api.featuredmods.FeaturedModService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.ByteArrayInputStream; +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.util.List; + +import static junit.framework.TestCase.assertTrue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.is; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class ExeUploaderServiceTest { + private ExeUploaderService instance; + + private Path temporaryDirectory; + private Path finalDirectory; + + @Mock + private ContentService contentService; + @Mock + private FafApiProperties apiProperties; + @Mock + private FafApiProperties.Deployment deployment; + @Mock + private FeaturedModService featuredModService; + private InputStream exeDataInputStream; + + private FeaturedModFile featuredModFile; + + @BeforeEach + public void setUp() { + instance = new ExeUploaderService(contentService, apiProperties, featuredModService); + exeDataInputStream = new ByteArrayInputStream(new byte[]{1, 2, 3, 4}); + + featuredModFile = new FeaturedModFile(); + featuredModFile.setName("ForgedAlliance.exe"); + featuredModFile.setVersion(1); + featuredModFile.setFileId((short) 1); + } + + @AfterEach + public void after() { + verifyNoMoreInteractions(contentService, apiProperties, deployment, featuredModService); + } + + @Nested + class WithTempDir { + @TempDir + public Path baseTemporaryDirectory; + + @BeforeEach + public void setUp() throws IOException { + temporaryDirectory = Files.createDirectory(baseTemporaryDirectory.resolve("temp")); + finalDirectory = Files.createDirectory(baseTemporaryDirectory.resolve("final")); + + when(apiProperties.getDeployment()).thenReturn(deployment); + when(contentService.createTempDir()).thenReturn(temporaryDirectory); + } + + @Test + public void testProcessUploadBeta() { + String modName = "fafbeta"; + + String finalExeDestination = finalDirectory.toAbsolutePath() + "/ForgedAlliance.1.exe"; + when(deployment.getForgedAllianceBetaExePath()).thenReturn(finalExeDestination); + + when(featuredModService.getFile(modName, null, "ForgedAlliance.exe")).thenReturn( + featuredModFile); + instance.processUpload(exeDataInputStream, modName); + + assertTrue(Files.exists(Paths.get(finalExeDestination))); + ArgumentCaptor> modFilesCaptor = ArgumentCaptor.forClass(List.class); + verify(featuredModService).save(eq(modName), eq((short) featuredModFile.getVersion()), modFilesCaptor.capture()); + assertThat(modFilesCaptor.getValue().size(), is(1)); + assertThat(modFilesCaptor.getValue(), hasItem(featuredModFile)); + + verify(contentService).createTempDir(); + verify(apiProperties, atLeastOnce()).getDeployment(); + verify(deployment).getForgedAllianceBetaExePath(); + verify(featuredModService).getFile(modName, null, "ForgedAlliance.exe"); + } + + @Test + public void testProcessUploadDevelop() { + String modName = "fafdevelop"; + + String finalExeDestination = finalDirectory.toAbsolutePath() + "/ForgedAlliance.1.exe"; + when(deployment.getForgedAllianceDevelopExePath()).thenReturn(finalExeDestination); + + when(featuredModService.getFile(modName, null, "ForgedAlliance.exe")).thenReturn( + featuredModFile); + instance.processUpload(exeDataInputStream, modName); + + assertTrue(Files.exists(Paths.get(finalExeDestination))); + ArgumentCaptor> modFilesCaptor = ArgumentCaptor.forClass(List.class); + verify(featuredModService).save(eq(modName), eq((short) featuredModFile.getVersion()), modFilesCaptor.capture()); + assertThat(modFilesCaptor.getValue().size(), is(1)); + assertThat(modFilesCaptor.getValue(), hasItem(featuredModFile)); + + verify(contentService).createTempDir(); + verify(apiProperties, atLeastOnce()).getDeployment(); + verify(deployment).getForgedAllianceDevelopExePath(); + verify(featuredModService).getFile(modName, null, "ForgedAlliance.exe"); + } + } + + @Test + public void testProcessUploadIsForbidden() { + String modName = "faf"; + Assertions.assertThrows(ApiException.class, () -> { + instance.processUpload(exeDataInputStream, modName); + }); + } +}