diff --git a/src/main/java/at/ac/uibk/gitsearch/es/model/ArtemisExerciseInfo.java b/src/main/java/at/ac/uibk/gitsearch/es/model/ArtemisExerciseInfo.java index b8f68089f494bd4be7cd5eee19b3ac42cbd8d615..93edd4dd14c8b8d381e9a47ecb2d891c0a6de1be 100644 --- a/src/main/java/at/ac/uibk/gitsearch/es/model/ArtemisExerciseInfo.java +++ b/src/main/java/at/ac/uibk/gitsearch/es/model/ArtemisExerciseInfo.java @@ -131,7 +131,9 @@ public class ArtemisExerciseInfo extends ExerciseInfo { exerciseInfo.setIsBasedOn(this.getIsBasedOn()); } exerciseInfo.setKeyword(this.getKeyword()); - exerciseInfo.setLanguage(this.getLanguage()); + if (!this.getLanguage().isEmpty()) { + exerciseInfo.setLanguage(this.getLanguage()); + } exerciseInfo.setLearningResourceType(this.getLearningResourceType()); exerciseInfo.setLicense(this.getLicense()); exerciseInfo.setMetadataVersion(this.getMetadataVersion()); @@ -205,8 +207,39 @@ public class ArtemisExerciseInfo extends ExerciseInfo { return exerciseInfo; } + /** + * Method checking if the mandatory metadata fields are contained and not empty + * + * @return true if all fields are present, false otherwise + */ + public boolean validateMandatoryFields() { + return ( + this.getMetadataVersion() != null && + !this.getMetadataVersion().isBlank() && + this.getTitle() != null && + !this.getTitle().isBlank() && + this.getDescription() != null && + !this.getDescription().isBlank() && + this.getKeyword() != null && + !this.getKeyword().isEmpty() && + this.getCreator() != null && + !this.getCreator().isEmpty() && + this.getPublisher() != null && + !this.getPublisher().isEmpty() && + this.getLicense() != null && + !this.getLicense().isBlank() && + this.getLanguage() != null && + !this.getLanguage().isEmpty() && + this.getFormat() != null && + !this.getFormat().isEmpty() && + this.getLearningResourceType() != null && + !this.getLearningResourceType().isBlank() + ); + } + /** * Used to merge attributes of ExerciseInfo into an ArtemisExerciseInfo class + * * @param exerciseInfo to merge */ public void appendExerciseInfo(ExerciseInfo exerciseInfo) { @@ -280,4 +313,26 @@ public class ArtemisExerciseInfo extends ExerciseInfo { INDIVIDUAL, TEAM, } + + /** + * Represents the mappings of difficulty values between + * Artemis and Gitserach. + * + * Example: Artemis difficulty value EASY maps to Gitserach difficulty value SIMPLE + */ + public enum DifficultyMapping { + EASY("SIMPLE"), + MEDIUM("MEDIUM"), + HARD("ADVANCED"); + + private final String difficulty; + + DifficultyMapping(String difficulty) { + this.difficulty = difficulty; + } + + public String getDifficulty() { + return this.difficulty; + } + } } diff --git a/src/main/java/at/ac/uibk/gitsearch/service/ExerciseService.java b/src/main/java/at/ac/uibk/gitsearch/service/ExerciseImportService.java similarity index 61% rename from src/main/java/at/ac/uibk/gitsearch/service/ExerciseService.java rename to src/main/java/at/ac/uibk/gitsearch/service/ExerciseImportService.java index 2388fcf1f2956bd50311979fe08b333fabaca9d2..26de1487b4b49cbdbb93c4786183b80f08fc49cb 100644 --- a/src/main/java/at/ac/uibk/gitsearch/service/ExerciseService.java +++ b/src/main/java/at/ac/uibk/gitsearch/service/ExerciseImportService.java @@ -2,15 +2,14 @@ package at.ac.uibk.gitsearch.service; import at.ac.uibk.gitsearch.es.model.ArtemisExerciseInfo; import at.ac.uibk.gitsearch.properties.ApplicationProperties; -import com.fasterxml.jackson.core.JsonFactory; +import at.ac.uibk.gitsearch.service.dto.MetadataUserDTO; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; import java.io.FileWriter; import java.io.IOException; import java.io.InputStream; @@ -19,10 +18,9 @@ import java.net.URISyntaxException; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; -import java.nio.file.Path; import java.nio.file.Paths; import java.util.Objects; -import java.util.zip.ZipEntry; +import java.util.stream.Collectors; import java.util.zip.ZipInputStream; import javax.ws.rs.client.Client; import javax.ws.rs.client.ClientBuilder; @@ -30,8 +28,6 @@ import javax.ws.rs.client.WebTarget; import net.minidev.json.JSONObject; import net.minidev.json.JSONStyle; import org.apache.commons.io.FileUtils; -import org.apache.commons.lang3.StringUtils; -import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.lib.Repository; import org.gitlab4j.api.GitLabApiException; import org.gitlab4j.api.models.Group; @@ -41,47 +37,39 @@ import org.springframework.stereotype.Service; @Service @SuppressWarnings({ "PMD.GodClass", "PMD.CognitiveComplexity", "PMD.CyclomaticComplexity", "PMD.NPathComplexity" }) -public class ExerciseService { +public class ExerciseImportService { private static final String DIFFICULTY = "difficulty"; private static final String TMP_DIR_PROPERTY = "java.io.tmpdir"; - private final Logger log = LoggerFactory.getLogger(ExerciseService.class); + private final Logger log = LoggerFactory.getLogger(ExerciseImportService.class); private final ApplicationProperties applicationProperties; - private final FileService fileService; - private final GitlabService gitlabService; - public ExerciseService(ApplicationProperties applicationProperties, FileService fileService, GitlabService gitlabService) { + private final ZipService zipService; + + public ExerciseImportService(ApplicationProperties applicationProperties, ZipService zipService, GitlabService gitlabService) { this.applicationProperties = applicationProperties; - this.fileService = fileService; + this.zipService = zipService; this.gitlabService = gitlabService; } /** * Retrieve a zipped Artemis Exercise from a given URL * and store it in a temporary directory + * * @param exerciseUrl the url of the exposed exercise * @throws ArtemisImportError */ @SuppressWarnings("PMD.AvoidCatchingGenericException") public void importExerciseFromArtemis(String exerciseUrl) throws ArtemisImportError { - String exerciseToken = exerciseUrl.split("/")[exerciseUrl.split("/").length - 1]; - Client client = ClientBuilder.newClient(); + String exerciseToken = getTokenFromUrl(exerciseUrl); - WebTarget target = client.target(exerciseUrl); - try ( - InputStream inputStream = target - .request() - .header("Authorization", getApiKeyOfRegisteredConnectorByUrl(new URI(exerciseUrl).getAuthority())) - .accept(javax.ws.rs.core.MediaType.APPLICATION_OCTET_STREAM) - .get(InputStream.class); - ZipInputStream zipInput = new ZipInputStream(inputStream) - ) { - storeZipInTmp(zipInput, exerciseToken); + try (ZipInputStream zipInput = getZipInputStreamFromUrl(exerciseUrl);) { + zipService.storeZipInTmp(zipInput, exerciseToken); } catch (URISyntaxException e) { log.error("Unable to add apiKey to the import request for [{}]. Try to request resource without apiKey.", exerciseUrl); } catch (Exception e) { @@ -92,6 +80,18 @@ public class ExerciseService { } } + protected ZipInputStream getZipInputStreamFromUrl(String exerciseUrl) throws URISyntaxException { + Client client = ClientBuilder.newClient(); + WebTarget target = client.target(exerciseUrl); + + InputStream inputStream = target + .request() + .header("Authorization", getApiKeyOfRegisteredConnectorByUrl(new URI(exerciseUrl).getAuthority())) + .accept(javax.ws.rs.core.MediaType.APPLICATION_OCTET_STREAM) + .get(InputStream.class); + return new ZipInputStream(inputStream); + } + /** * Import exercise to Gitlab. * Sequentially executes following processes: @@ -100,11 +100,15 @@ public class ExerciseService { * - Locally checking out the new created repository * - Copy, commit and push files to the new repository * - * @param exerciseInfo of the exercise to import - * @param exerciseToken of the stored exercise (in the temporary directory) to import + * @param exerciseInfo of the exercise to import + * @param exerciseToken of the stored exercise (in the temporary directory) to + * import */ public void importExercise(ArtemisExerciseInfo exerciseInfo, String exerciseToken, Integer gitlabGroupId) - throws GitLabApiException, GitAPIException, IOException, ArtemisImportError { + throws GitLabApiException, IOException, ArtemisImportError { + sanitizeExerciseInfo(exerciseInfo); + validateExerciseInfo(exerciseInfo); + applyExerciseInfoChanges(exerciseInfo, exerciseToken); renameFiles(exerciseToken); @@ -122,16 +126,19 @@ public class ExerciseService { /** * Retrieves metadata from imported Artemis exercise + * * @param exerciseToken of the imported exercise * @return Content of exercise's 'metadata.yaml' and 'artemis.yaml' */ public ArtemisExerciseInfo getArtemisExerciseInfo(String exerciseToken) throws IOException { - ObjectMapper mapper = new ObjectMapper(new JsonFactory()); - mapper - .enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING) - .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) - .enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY) - .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + ObjectMapper mapper = JsonMapper + .builder() + .configure(DeserializationFeature.READ_ENUMS_USING_TO_STRING, true) + .configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS, true) + .configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .build(); + File exerciseDetailsFile = Objects.requireNonNull( new File(System.getProperty(TMP_DIR_PROPERTY), exerciseToken) .listFiles((file, name) -> name.startsWith("Exercise-Details-") && name.endsWith(".json")) @@ -145,7 +152,8 @@ public class ExerciseService { } /** - * Utility method used to map Artemis Exercise metadata values to the ones used in + * Utility method used to map Artemis Exercise metadata values to the ones used + * in * Sharing Metadata * * @param jsonFile to parse the values from @@ -155,19 +163,14 @@ public class ExerciseService { ObjectMapper mapper = new ObjectMapper(); JSONObject jsonObject = mapper.readValue(jsonFile, JSONObject.class); if (jsonObject.containsKey(DIFFICULTY)) { - switch (jsonObject.get(DIFFICULTY).toString()) { - case "EASY": - jsonObject.put(DIFFICULTY, "SIMPLE"); - break; - case "MEDIUM": - jsonObject.put(DIFFICULTY, "MEDIUM"); - break; - case "HARD": - jsonObject.put(DIFFICULTY, "ADVANCED"); - break; - default: // should never happen - jsonObject.put(DIFFICULTY, "MEDIUM"); - break; + try { + jsonObject.put(DIFFICULTY, ArtemisExerciseInfo.DifficultyMapping.valueOf(jsonObject.get(DIFFICULTY).toString())); + } catch (IllegalArgumentException iae) { + log.warn( + "Artemis difficulty value '{}' could not be mapped to its equivalent Gitsearch value. Skipping..", + jsonObject.get(DIFFICULTY) + ); + jsonObject.remove(DIFFICULTY); } } @@ -183,6 +186,10 @@ public class ExerciseService { jsonObject.put("format", "artemis"); } + if (!jsonObject.containsKey("learningResourceType")) { + jsonObject.put("learningResourceType", "artemis"); + } + if (!jsonObject.containsKey("structure")) { jsonObject.put("structure", "atomic"); } @@ -203,7 +210,8 @@ public class ExerciseService { /** * Utility method used to extract Artemis metadata into a 'artemis.yaml' file - * @param jsonMetadataFile where the exercise metadata is stored + * + * @param jsonMetadataFile where the exercise metadata is stored * @param artemisExerciseInfo the entity of the stored metadata * @throws IOException if an error occurs */ @@ -223,118 +231,10 @@ public class ExerciseService { } /** - * Used to temporary store content of Artemis' exercises Zip-Files into a tmp-subdirectory - * - * Recursion is used in case of nested Zip-Files + * Utility method used to apply metadata changes to the exercise's + * 'metadata.yaml' file * - * @param zipInputStream containing the Zip-File to unzip - * @param subDir name of the tmp-subdirectory - */ - @SuppressWarnings("PMD.AssignmentInOperand") - private void storeZipInTmp(ZipInputStream zipInputStream, String subDir) throws IOException { - Path targetDir; - - if (subDir.startsWith(System.getProperty(TMP_DIR_PROPERTY))) { - targetDir = Files.createDirectories(Paths.get(subDir)); - } else { - targetDir = Files.createDirectories(Paths.get(System.getProperty(TMP_DIR_PROPERTY), subDir)); - } - - ZipEntry zipEntry; - - while ((zipEntry = zipInputStream.getNextEntry()) != null) { - if (zipEntry.getName().contains(".git")) { // important as Artemis exercises come in submodules - continue; - } - - Path zipEntryPath = Paths.get(targetDir.toString(), zipEntry.getName()); - - if (isZipFile(zipEntry)) { - writeFile(zipEntryPath.toFile(), zipInputStream); - String newSubDir; - if (StringUtils.endsWithAny(zipEntry.getName(), new String[] { "-exercise.zip", "-solution.zip", "-tests.zip" })) { - newSubDir = Paths.get(targetDir.getParent().toString(), getRepoNameFromZipEntry(zipEntry)).toString(); - } else { - newSubDir = - Paths.get(targetDir.toString(), zipEntry.getName().substring(0, zipEntry.getName().lastIndexOf("."))).toString(); - } - - storeZipInTmp(new ZipInputStream(new FileInputStream(zipEntryPath.toFile())), newSubDir); - if (Files.deleteIfExists(zipEntryPath)) { - log.warn("Unable to delete temporary Zip file [{}] after extraction. Skipping..", zipEntryPath); - } - } else if (zipEntry.isDirectory()) { - Files.createDirectory(zipEntryPath); - } else { - writeFile(zipEntryPath.toFile(), zipInputStream); - } - } - cleanUpTmpDirectory(targetDir.toFile()); - zipInputStream.close(); - } - - /** - * Utility method used to check if a ZipEntry represents a Zip file - * @param zipEntry to check - * @return true if zipEntry is a Zip file, false otherwise - */ - private boolean isZipFile(ZipEntry zipEntry) { - return zipEntry.getName().endsWith(".zip"); - } - - /** - * Cleans up a temporary directory by removing it if empty - * or scheduling its deletion with a delay of 60 minutes, otherwise. - * - * @param tmpDir to clean up - */ - private void cleanUpTmpDirectory(File tmpDir) { - try { - if (tmpDir.exists() && tmpDir.isDirectory() && fileService.isEmpty(tmpDir.toPath())) { - Files.delete(tmpDir.toPath()); - } - fileService.scheduleForDeletion(tmpDir.toPath(), 60); - } catch (IOException e) { - log.error("An error occurred while cleaning up a temporary directory: ", e); - } - } - - /** - * Utility method used to extract the repository name from Zip files - * representing git submodules. (Artemis exercise exports come in - * submodules for 'exercise', 'solution' and 'test') - * @param zipEntry to get the repository name from - * @return the repository name - */ - private String getRepoNameFromZipEntry(ZipEntry zipEntry) { - return zipEntry.getName().substring(zipEntry.getName().lastIndexOf("-") + 1, zipEntry.getName().lastIndexOf(".")); - } - - /** - * Method used to write files from a zipInputStream - * @param entryFile File to write - * @param zipInputStream Byte stream to read from - * @throws IOException - */ - @SuppressWarnings("PMD.AssignmentInOperand") - private void writeFile(File entryFile, ZipInputStream zipInputStream) throws IOException { - byte[] buffer = new byte[1024]; - - if (!entryFile.getParentFile().exists()) { - Files.createDirectories(entryFile.getParentFile().toPath()); - } - - try (FileOutputStream fos = new FileOutputStream(entryFile)) { - int len; - while ((len = zipInputStream.read(buffer)) > 0) { - fos.write(buffer, 0, len); - } - } - } - - /** - * Utility method used to apply metadata changes to the exercise's 'metadata.yaml' file - * @param exerciseInfo containing the metadata values + * @param exerciseInfo containing the metadata values * @param exerciseToken of the exercise to update * @throws IOException */ @@ -351,13 +251,14 @@ public class ExerciseService { /** * Used to validate apiKeys by checking if they match with * one of the registeredConnectors' keys stored in application-(dev|prod).yml + * * @param apiKey the apikey to validate * @return true if equal to the local one, false otherwise */ public boolean validate(String apiKey) { for (ApplicationProperties.RegisteredConnector registeredConnector : applicationProperties.getRegisteredConnectors()) { if (registeredConnector.getAccessToken().equals(apiKey)) { - log.debug("ApiKey validated correctly against [" + registeredConnector.getUrl() + "]"); + log.debug("ApiKey validated correctly against [{} ]", registeredConnector.getUrl()); return true; } } @@ -365,8 +266,10 @@ public class ExerciseService { } /** - * Method that retrieves the ApiKey of a Registered Connector configured in 'application-dev/prod.yml' + * Method that retrieves the ApiKey of a Registered Connector configured in + * 'application-dev/prod.yml' * given the URL of the connector + * * @param urlAuthority URL Authority/Domain of the connector * @return the ApiKey, or Null if no connector with the given url is found */ @@ -409,4 +312,85 @@ public class ExerciseService { log.error("Error while renaming file: {}", ioe.getMessage()); } } + + /** + * Sanitize the provided input fields for the exercise information, by escaping + * all potential tags' brackets + * + * @param exerciseInfo of the imported exercise + */ + private void sanitizeExerciseInfo(ArtemisExerciseInfo exerciseInfo) { + exerciseInfo.setMetadataVersion(exerciseInfo.getMetadataVersion()); + exerciseInfo.setTitle(replaceTagCharacters(exerciseInfo.getTitle())); + exerciseInfo.setDescription(replaceTagCharacters(exerciseInfo.getDescription())); + if (exerciseInfo.getKeyword() != null) { + exerciseInfo.setKeyword(exerciseInfo.getKeyword().stream().map(this::replaceTagCharacters).collect(Collectors.toList())); + } + if (exerciseInfo.getCreator() != null) { + exerciseInfo.setCreator(exerciseInfo.getCreator().stream().map(creator -> creator.sanitize()).collect(Collectors.toList())); + } + if (exerciseInfo.getPublisher() != null) { + exerciseInfo.setPublisher( + exerciseInfo.getPublisher().stream().map(publisher -> publisher.sanitize()).collect(Collectors.toList()) + ); + } + exerciseInfo.setLicense(replaceTagCharacters(exerciseInfo.getLicense())); + if (exerciseInfo.getLanguage() != null) { + exerciseInfo.setLanguage(exerciseInfo.getLanguage().stream().map(this::replaceTagCharacters).collect(Collectors.toList())); + } + if (exerciseInfo.getFormat() != null) { + exerciseInfo.setFormat(exerciseInfo.getFormat().stream().map(this::replaceTagCharacters).collect(Collectors.toList())); + } + exerciseInfo.setLearningResourceType(replaceTagCharacters(exerciseInfo.getLearningResourceType())); + } + + /** + * Method used to validate the exercise information of the imported exercise + * + * @param exerciseInfo of the imported exercise + * @throws ArtemisImportError if the exercise's information is invalid or + * incomplete + */ + public void validateExerciseInfo(ArtemisExerciseInfo exerciseInfo) throws ArtemisImportError { + if (!exerciseInfo.validateMandatoryFields()) { // Check mandatory fields + throw new ArtemisImportError("exercise.import.error.invalidMetadata"); + } + for (String keyword : exerciseInfo.getKeyword()) { + if (keyword.isBlank()) { + throw new ArtemisImportError("exercise.import.error.invalidKeyword"); + } + } + for (String programmingLanguage : exerciseInfo.getProgrammingLanguage()) { + if (programmingLanguage.isBlank()) { + throw new ArtemisImportError("exercise.import.error.invalidProgrammingLanguage"); + } + } + for (MetadataUserDTO creator : exerciseInfo.getCreator()) { + if (!creator.isValid()) { + throw new ArtemisImportError("exercise.import.error.invalidCreator"); + } + } + for (MetadataUserDTO publisher : exerciseInfo.getPublisher()) { + if (!publisher.isValid()) { + throw new ArtemisImportError("exercise.import.error.invalidPublisher"); + } + } + } + + /** + * Method responsible for extracting an exercise's token from + * its Url. + * @param exerciseUrl to extract the token from + * @return The exercise's token + */ + public String getTokenFromUrl(String exerciseUrl) { + return exerciseUrl.split("/")[exerciseUrl.split("/").length - 1]; + } + + private String replaceTagCharacters(String input) { + if (input != null) { + return input.replace("<", "<").replace(">", ">"); + } + return null; + } } diff --git a/src/main/java/at/ac/uibk/gitsearch/service/ZipService.java b/src/main/java/at/ac/uibk/gitsearch/service/ZipService.java new file mode 100644 index 0000000000000000000000000000000000000000..d88f6093c53fb6a947d77f1134b3b5e46511cd38 --- /dev/null +++ b/src/main/java/at/ac/uibk/gitsearch/service/ZipService.java @@ -0,0 +1,169 @@ +package at.ac.uibk.gitsearch.service; + +import at.ac.uibk.gitsearch.es.model.ArtemisExerciseInfo; +import at.ac.uibk.gitsearch.properties.ApplicationProperties; +import at.ac.uibk.gitsearch.service.dto.MetadataUserDTO; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.FileWriter; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import javax.ws.rs.client.Client; +import javax.ws.rs.client.ClientBuilder; +import javax.ws.rs.client.WebTarget; +import net.minidev.json.JSONObject; +import net.minidev.json.JSONStyle; +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jgit.lib.Repository; +import org.gitlab4j.api.GitLabApiException; +import org.gitlab4j.api.models.Group; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +@Service +public class ZipService { + + private static final String TMP_DIR_PROPERTY = "java.io.tmpdir"; + + private final Logger log = LoggerFactory.getLogger(ExerciseImportService.class); + + private final FileService fileService; + + public ZipService(FileService fileService) { + this.fileService = fileService; + } + + /** + * Used to temporary store content of Artemis' exercises Zip-Files into a + * tmp-subdirectory + * + * Recursion is used in case of nested Zip-Files + * + * @param zipInputStream containing the Zip-File to unzip + * @param subDir name of the tmp-subdirectory + */ + @SuppressWarnings({ "PMD.AssignmentInOperand", "PMD.CognitiveComplexity" }) + public void storeZipInTmp(ZipInputStream zipInputStream, String subDir) throws IOException { + Path targetDir; + + if (subDir.startsWith(System.getProperty(TMP_DIR_PROPERTY))) { + targetDir = Files.createDirectories(Paths.get(subDir)); + } else { + targetDir = Files.createDirectories(Paths.get(System.getProperty(TMP_DIR_PROPERTY), subDir)); + } + + ZipEntry zipEntry; + + while ((zipEntry = zipInputStream.getNextEntry()) != null) { + if (zipEntry.getName().contains(".git")) { // important as Artemis exercises come in submodules + continue; + } + + Path zipEntryPath = Paths.get(targetDir.toString(), zipEntry.getName()); + + if (isZipFile(zipEntry)) { + writeFile(zipEntryPath.toFile(), zipInputStream); + String newSubDir; + if (StringUtils.endsWithAny(zipEntry.getName(), new String[] { "-exercise.zip", "-solution.zip", "-tests.zip" })) { + newSubDir = Paths.get(targetDir.getParent().toString(), getRepoNameFromZipEntry(zipEntry)).toString(); + } else { + newSubDir = + Paths.get(targetDir.toString(), zipEntry.getName().substring(0, zipEntry.getName().lastIndexOf("."))).toString(); + } + + storeZipInTmp(new ZipInputStream(new FileInputStream(zipEntryPath.toFile())), newSubDir); + if (Files.deleteIfExists(zipEntryPath)) { + log.warn("Unable to delete temporary Zip file [{}] after extraction. Skipping..", zipEntryPath); + } + } else if (zipEntry.isDirectory()) { + Files.createDirectory(zipEntryPath); + } else { + writeFile(zipEntryPath.toFile(), zipInputStream); + } + } + cleanUpTmpDirectory(targetDir.toFile()); + zipInputStream.close(); + } + + /** + * Utility method used to check if a ZipEntry represents a Zip file + * + * @param zipEntry to check + * @return true if zipEntry is a Zip file, false otherwise + */ + private boolean isZipFile(ZipEntry zipEntry) { + return zipEntry.getName().endsWith(".zip"); + } + + /** + * Cleans up a temporary directory by removing it if empty + * or scheduling its deletion with a delay of 60 minutes, otherwise. + * + * @param tmpDir to clean up + */ + private void cleanUpTmpDirectory(File tmpDir) { + try { + if (tmpDir.exists() && tmpDir.isDirectory() && fileService.isEmpty(tmpDir.toPath())) { + Files.delete(tmpDir.toPath()); + } + fileService.scheduleForDeletion(tmpDir.toPath(), 60); + } catch (IOException e) { + log.error("An error occurred while cleaning up a temporary directory: ", e); + } + } + + /** + * Utility method used to extract the repository name from Zip files + * representing git submodules. (Artemis exercise exports come in + * submodules for 'exercise', 'solution' and 'test') + * + * @param zipEntry to get the repository name from + * @return the repository name + */ + private String getRepoNameFromZipEntry(ZipEntry zipEntry) { + return zipEntry.getName().substring(zipEntry.getName().lastIndexOf("-") + 1, zipEntry.getName().lastIndexOf(".")); + } + + /** + * Method used to write files from a zipInputStream + * + * @param entryFile File to write + * @param zipInputStream Byte stream to read from + * @throws IOException + */ + @SuppressWarnings("PMD.AssignmentInOperand") + private void writeFile(File entryFile, ZipInputStream zipInputStream) throws IOException { + byte[] buffer = new byte[1024]; + + if (!entryFile.getParentFile().exists()) { + Files.createDirectories(entryFile.getParentFile().toPath()); + } + + try (FileOutputStream fos = new FileOutputStream(entryFile)) { + int len; + while ((len = zipInputStream.read(buffer)) > 0) { + fos.write(buffer, 0, len); + } + } + } +} diff --git a/src/main/java/at/ac/uibk/gitsearch/service/dto/MetadataUserDTO.java b/src/main/java/at/ac/uibk/gitsearch/service/dto/MetadataUserDTO.java index fcf92dd2b4f4e92a2a34adf21dd8f50c65a13e80..d9d57086c171b030ad5a91a909bd11b16d3c0f70 100644 --- a/src/main/java/at/ac/uibk/gitsearch/service/dto/MetadataUserDTO.java +++ b/src/main/java/at/ac/uibk/gitsearch/service/dto/MetadataUserDTO.java @@ -2,6 +2,7 @@ package at.ac.uibk.gitsearch.service.dto; import com.fasterxml.jackson.annotation.JsonInclude; import java.util.Objects; +import java.util.regex.Pattern; import javax.validation.constraints.Email; import javax.validation.constraints.Size; @@ -59,4 +60,33 @@ public class MetadataUserDTO { public String toString() { return "MetadataUserDTO{" + "name='" + name + '\'' + ", affiliation='" + affiliation + '\'' + ", email='" + email + '\'' + '}'; } + + public boolean isValid() { + String emailRegex = "^[A-Za-z0-9+_.-]+@(.+)$"; + + Pattern emailPattern = Pattern.compile(emailRegex); + + return ( + !this.getName().isBlank() && + !this.getAffiliation().isBlank() && + !this.getEmail().isBlank() && + emailPattern.matcher(this.getEmail()).matches() + ); + } + + /** + * Basic sanitization method replacing brackets with its corresponding escaped symbol + * + * @return the sanitized MetadataUserDTO + */ + public MetadataUserDTO sanitize() { + this.affiliation = replaceTagCharacters(this.affiliation); + this.name = replaceTagCharacters(this.name); + this.email = replaceTagCharacters(this.email); + return this; + } + + private String replaceTagCharacters(String input) { + return input.replace("<", "<").replace(">", ">"); + } } diff --git a/src/main/java/at/ac/uibk/gitsearch/web/rest/ExerciseResource.java b/src/main/java/at/ac/uibk/gitsearch/web/rest/ExerciseResource.java index c9df71446d10e5e23d75ea434407ac71429e922e..7c55d34a47b011800955cda2ea9e83a7d6e90caf 100644 --- a/src/main/java/at/ac/uibk/gitsearch/web/rest/ExerciseResource.java +++ b/src/main/java/at/ac/uibk/gitsearch/web/rest/ExerciseResource.java @@ -3,7 +3,7 @@ package at.ac.uibk.gitsearch.web.rest; import at.ac.uibk.gitsearch.es.model.ArtemisExerciseInfo; import at.ac.uibk.gitsearch.security.SecurityUtils; import at.ac.uibk.gitsearch.service.ArtemisImportError; -import at.ac.uibk.gitsearch.service.ExerciseService; +import at.ac.uibk.gitsearch.service.ExerciseImportService; import at.ac.uibk.gitsearch.service.GitlabService; import at.ac.uibk.gitsearch.service.SearchService; import at.ac.uibk.gitsearch.service.SearchService.ExtractionDepth; @@ -74,7 +74,7 @@ public class ExerciseResource { @Autowired @SuppressWarnings({ "PMD.ImmutableField", "PMD.AvoidDuplicateLiterals" }) - private ExerciseService exerciseService; + private ExerciseImportService exerciseImportService; @Autowired @SuppressWarnings({ "PMD.ImmutableField", "PMD.AvoidDuplicateLiterals" }) @@ -335,16 +335,14 @@ public class ExerciseResource { @RequestParam("apiKey") Optional<String> apiKey, HttpServletRequest request ) throws ArtemisImportError, MalformedURLException, URISyntaxException { - if (apiKey.isPresent() && exerciseService.validate(apiKey.get())) { - exerciseService.importExerciseFromArtemis(exerciseUrl); + if (apiKey.isPresent() && exerciseImportService.validate(apiKey.get())) { + exerciseImportService.importExerciseFromArtemis(exerciseUrl); } else { return ResponseEntity.status(401).body(null); } String baseUrl = getBaseUrl(request); - String exerciseToken = exerciseUrl.split("/")[exerciseUrl.split("/").length - 1]; - - return ResponseEntity.ok(new URL(baseUrl + "/import/" + exerciseToken).toURI()); + return ResponseEntity.ok(new URL(baseUrl + "/import/" + exerciseImportService.getTokenFromUrl(exerciseUrl)).toURI()); } /** @@ -360,7 +358,7 @@ public class ExerciseResource { throws IOException { if (SecurityUtils.isAuthenticated()) { try { - return ResponseEntity.ok(exerciseService.getArtemisExerciseInfo(exerciseToken)); + return ResponseEntity.ok(exerciseImportService.getArtemisExerciseInfo(exerciseToken)); } catch (NullPointerException npe) { return ResponseEntity.notFound().build(); } @@ -387,13 +385,23 @@ public class ExerciseResource { @PathVariable("gitlabGroupId") Integer gitlabGroupId ) throws GitLabApiException, GitAPIException, IOException, ArtemisImportError { if (SecurityUtils.isAuthenticated()) { - exerciseService.importExercise(exerciseInfo, exerciseToken, gitlabGroupId); + exerciseImportService.importExercise(exerciseInfo, exerciseToken, gitlabGroupId); return Response.ok().build(); } else { return Response.status(401).build(); } } + @PostMapping("/exercise/validate-exercise-info") + public ResponseEntity<String> validateExerciseInfo(@RequestBody ArtemisExerciseInfo exerciseInfo) { + try { + exerciseImportService.validateExerciseInfo(exerciseInfo); + return ResponseEntity.ok().build(); + } catch (ArtemisImportError e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + /** * Utility method used to retrieve the Base URL from a HTTP request * @param request the request to extract the Url from diff --git a/src/main/webapp/app/exercise/import/exercise-import.component.html b/src/main/webapp/app/exercise/import/exercise-import.component.html index 2442f92f246d436b1640dbad621b688632a809e3..b25d71086bd213855402b3d5ff8e578932f83533 100644 --- a/src/main/webapp/app/exercise/import/exercise-import.component.html +++ b/src/main/webapp/app/exercise/import/exercise-import.component.html @@ -2,6 +2,19 @@ <jhi-alert-error></jhi-alert-error> <h2 jhiTranslate="exercise.import.pageTitle">Import exercise</h2> +<div *ngIf="!exerciseInfo && errorStatus"> + <div class="w-75 container-fluid align-middle"> + <div *ngIf="errorStatus" class="row justify-content-center text-center"> + <span class="display-1 mb-5">{{ errorStatus }}</span> + </div> + <div class="row justify-content-center text-center"> + <span class="display-3 mb-5" [jhiTranslate]="occurredError">An error occurred</span> + </div> + <div class="row justify-content-center text-center"> + <span class="display-4" jhiTranslate="exercise.import.error.retry">Please retry to import the exercise</span> + </div> + </div> +</div> <div *ngIf="exerciseInfo != undefined"> <div class="row justify-content-center"> <div class="col-8"> diff --git a/src/main/webapp/app/exercise/import/exercise-import.component.spec.ts b/src/main/webapp/app/exercise/import/exercise-import.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..d4c5f7f9f717dd98c1343326ccf52f49a975ecab --- /dev/null +++ b/src/main/webapp/app/exercise/import/exercise-import.component.spec.ts @@ -0,0 +1,266 @@ +import { HttpClient, HttpHandler } from '@angular/common/http'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { ExerciseImportComponent } from 'app/exercise/import/exercise-import.component'; +import { ExerciseService } from 'app/exercise/service/exercise.service'; +import { ArtemisExerciseInfo } from 'app/shared/model/artemis-exercise-info.model'; +import { IInteractivityType } from 'app/shared/model/exercise.model'; +import { GitlabGroup, IVisibility } from 'app/shared/model/gitlab-group.model'; +import { SearchResultDTO } from 'app/shared/model/search/search-result-dto.model'; +import { of, throwError } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; + +describe('Exercise Import Component', () => { + let comp: ExerciseImportComponent; + let fixture: ComponentFixture<ExerciseImportComponent>; + let service: ExerciseService; + + const exerciseInfoTemplate = new ArtemisExerciseInfo( + [], + '', + [], + [], + false, + '', + 0, + '', + '', + [], + [], + [], + '', + '', + '', + {} as IInteractivityType, + [], + [], + [], + new Date(), + '', + '', + '', + {} as SearchResultDTO, + ['Java'], + '', + [], + 0, + [], + '', + '', + [], + [], + '', + 'testExTitle', + '', + '', + 0, + 0, + 0, + false + ); + + const group: GitlabGroup = { + id: 1, + name: 'testGroup', + path: 'testPath', + description: '', + visibility: IVisibility.PUBLIC, + avatarUrl: '', + webUrl: '', + fullName: '', + fullPath: '', + parentId: 0, + }; + + const newPerson = { name: 'newPerson', email: 'person@email', affiliation: 'newAffiliation' }; + const newPersonCopy = { name: 'newPerson', email: 'person@email', affiliation: 'newAffiliation' }; + const personToRemove = { name: 'personToRemove', email: 'person@toremove', affiliation: '' }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ExerciseImportComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { + snapshot: { + params: { + token: 'exercise-test-token', + }, + queryParams: { + callback: 'https://callback-url.test', + }, + }, + }, + }, + ExerciseImportComponent, + ExerciseService, + HttpClient, + HttpHandler, + ], + }) + .overrideTemplate(ExerciseImportComponent, '') + .compileComponents(); + + fixture = TestBed.createComponent(ExerciseImportComponent); + comp = fixture.componentInstance; + service = TestBed.inject(ExerciseService); + }); + + describe('init', () => { + it('Should load on init', () => { + // GIVEN + jest.spyOn(service, 'importExerciseInfoFromArtemis').mockImplementation(() => of(exerciseInfoTemplate)); + + // WHEN + comp.ngOnInit(); + + // THEN + expect(comp.exerciseInfo?.title).toEqual('testExTitle'); + expect(comp.exerciseInfo?.programmingLanguage[0]).toEqual('Java'); + }); + }); + + describe('map metadata', () => { + it('Should map metadata values when importing', () => { + // GIVEN + comp.exerciseToken = 'TestToken'; + comp.exerciseInfo = exerciseInfoTemplate; + comp.exerciseInfo.difficulty = 'ADVANCED'; + comp.isPublisherSameAsCreator = true; + + jest.spyOn(comp, 'openGitlabPathSelectorModal').mockImplementation( + () => + new Promise(resolve => { + resolve(group); + }) + ); + jest.spyOn(service, 'submitExerciseInfoForImport').mockImplementation(() => of({} as ArtemisExerciseInfo)); + + // WHEN + comp.import(); + + // THEN + expect(comp.exerciseInfo.difficulty).toEqual(comp.exerciseInfo.difficulty.toLowerCase()); + expect(comp.exerciseInfo.publisher).toEqual(comp.exerciseInfo.creator); + }); + }); + + describe('add and remove creators', () => { + it('Should add and remove creators from list', () => { + // GIVEN + comp.exerciseInfo = exerciseInfoTemplate; + + // WHEN + comp.addPerson(newPerson, 'creator'); + comp.addPerson(personToRemove, 'creator'); + comp.removePerson(personToRemove, 'creator'); + + // THEN + expect(comp.exerciseInfo?.creator).toEqual([newPersonCopy]); + }); + }); + + describe('add and remove publishers', () => { + it('Should add and remove publishers from list', () => { + // GIVEN + comp.exerciseInfo = exerciseInfoTemplate; + + // WHEN + comp.addPerson(newPerson, 'publisher'); + comp.addPerson(personToRemove, 'publisher'); + comp.removePerson(personToRemove, 'publisher'); + + // THEN + expect(comp.exerciseInfo?.publisher).toEqual([newPersonCopy]); + }); + }); + + describe('validate button groups', () => { + it('Should validate form button groups', () => { + // GIVEN + comp.exerciseInfo = exerciseInfoTemplate; + comp.exerciseInfo.keyword = []; + comp.exerciseInfo.language = []; + const resultWithErrors = comp.hasValidationErrors(); + + comp.exerciseInfo.keyword.push('keyword'); + comp.setLanguage('english'); + const resultWithoutErrors = comp.hasValidationErrors(); + + // THEN + expect(resultWithErrors).toEqual(true); + expect(resultWithoutErrors).toEqual(false); + }); + }); + + describe('display import errors', () => { + it('should set unauthorized error', () => { + // GIVEN + const error = { + status: 401, + message: 'Unauthorized', + }; + + jest.spyOn(service, 'importExerciseInfoFromArtemis').mockReturnValue(throwError(() => error)); + + // WHEN + comp.ngOnInit(); + + // THEN + expect(comp.errorStatus).toBeDefined(); + expect(comp.occurredError).toEqual('exercise.import.error.unauthorized'); + }); + + it('should set not found error', () => { + // GIVEN + const error = { + status: 404, + message: 'Not Found', + }; + + jest.spyOn(service, 'importExerciseInfoFromArtemis').mockReturnValue(throwError(() => error)); + + // WHEN + comp.ngOnInit(); + + // THEN + expect(comp.errorStatus).toBeDefined(); + expect(comp.occurredError).toEqual('exercise.import.error.notFound'); + }); + + it('should set error', () => { + // GIVEN + const error = { + status: 500, + message: 'Internal Server Error', + }; + + jest.spyOn(service, 'importExerciseInfoFromArtemis').mockReturnValue(throwError(() => error)); + + // WHEN + comp.ngOnInit(); + + // THEN + expect(comp.errorStatus).toBeDefined(); + expect(comp.occurredError).toEqual('exercise.import.error.loadingError'); + }); + }); + + describe('Validate metadata on import', () => { + it('Should validate metadata on import', () => { + // GIVEN + comp.exerciseInfo = exerciseInfoTemplate; + comp.exerciseInfo.keyword = []; + comp.exerciseInfo.language = []; + jest.spyOn(service, 'validateExerciseInfo').mockReturnValue(of('0')); + + // WHEN + comp.import(); + + // THEN + expect(service.validateExerciseInfo).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/main/webapp/app/exercise/import/exercise-import.component.ts b/src/main/webapp/app/exercise/import/exercise-import.component.ts index 22207a08b889713086d3fa14af161a9aea770ffb..5e176d17e9e2aefa723a75ca427ce7dd38929b3b 100644 --- a/src/main/webapp/app/exercise/import/exercise-import.component.ts +++ b/src/main/webapp/app/exercise/import/exercise-import.component.ts @@ -10,6 +10,7 @@ import { GitlabPathSelectorComponent } from 'app/shared/gitlab/gitlab-path-selec import { GitlabGroup } from 'app/shared/model/gitlab-group.model'; import { COMMA, ENTER, TAB } from '@angular/cdk/keycodes'; import { MatChipInputEvent } from '@angular/material/chips'; +import { firstValueFrom } from 'rxjs'; @Component({ selector: 'jhi-import', @@ -31,6 +32,9 @@ export class ExerciseImportComponent implements OnInit { isLoading = false; isImporting = false; + errorStatus: number | undefined; + occurredError = 'exercise.import.error.loadingError'; + separatorKeysCodes = [ENTER, COMMA, TAB]; constructor( @@ -45,12 +49,12 @@ export class ExerciseImportComponent implements OnInit { this.isLoading = true; this.exerciseToken = this.route.snapshot.params['token']; if (this.exerciseToken) { - this.exerciseService.importExerciseInfoFromArtemis(this.exerciseToken).subscribe( - data => { + this.exerciseService.importExerciseInfoFromArtemis(this.exerciseToken).subscribe({ + next: data => { this.exerciseInfo = data; this.exerciseInfo.programmingLanguage = data['programmingLanguage']; this.exerciseInfo.programmingLanguage = this.exerciseInfo.programmingLanguage.map(programmingLanguage => - this.toTitleCase(programmingLanguage) + ExerciseImportComponent.toTitleCase(programmingLanguage) ); this.exerciseInfo.creator = data['creator']; if (this.exerciseInfo.language === undefined || this.exerciseInfo.language === null || this.exerciseInfo.language.length === 0) { @@ -68,10 +72,17 @@ export class ExerciseImportComponent implements OnInit { this.exerciseInfo.keyword = ['artemis']; } }, - error => { - this.jhiAlertService.addAlert({ type: 'danger', message: 'Following error occurred: '.concat(error.error) }); - } - ); + error: error => { + this.errorStatus = error.status; + if (error.status == 401) { + this.occurredError = 'exercise.import.error.unauthorized'; + } else if (error.status == 404) { + this.occurredError = 'exercise.import.error.notFound'; + } else { + this.occurredError = 'exercise.import.error.loadingError'; + } + }, + }); } this.isLoading = false; } @@ -80,33 +91,33 @@ export class ExerciseImportComponent implements OnInit { * Actual import function submitting import form to server */ import(): void { - if (this.exerciseInfo!.difficulty !== null) { - this.exerciseInfo!.difficulty = this.exerciseInfo!.difficulty?.toLowerCase(); - } - if (this.isPublisherSameAsCreator) { - if (this.exerciseInfo) { - this.exerciseInfo.publisher = this.exerciseInfo?.creator; - } - } - this.openGitlabPathSelectorModal().then(gitlabGroup => { - if (gitlabGroup !== undefined) { - this.isLoading = true; - this.isImporting = true; - this.exerciseService.submitExerciseInfoForImport(this.exerciseInfo!, this.exerciseToken!, gitlabGroup.id).subscribe( - () => { - window.location.href = this.route.snapshot.queryParams['callback']; - }, - error => { - this.jhiAlertService.addAlert({ type: 'danger', message: error.error.detail }); - window.scroll(0, 0); - this.isLoading = false; - this.isImporting = false; + this.isLoading = true; + this.applyPreprocessingStep(); + this.validateMetadata().then(res => { + if (res && res.length > 0) { + window.scroll({ top: 0, behavior: 'smooth' }); + this.isLoading = false; + } else { + this.isLoading = false; + this.openGitlabPathSelectorModal().then(gitlabGroup => { + if (gitlabGroup !== undefined) { + this.isLoading = true; + this.isImporting = true; + this.exerciseService.submitExerciseInfoForImport(this.exerciseInfo!, this.exerciseToken!, gitlabGroup.id).subscribe({ + next: () => { + window.location.href = this.route.snapshot.queryParams['callback']; + }, + error: err => { + this.jhiAlertService.addAlert({ type: 'danger', message: err.error.detail }); + window.scroll(0, 0); + this.isLoading = false; + this.isImporting = false; + }, + }); } - ); + }); } }); - this.isLoading = false; - this.isImporting = false; } /** @@ -126,18 +137,15 @@ export class ExerciseImportComponent implements OnInit { * @return true if invalid and false if valid */ hasValidationErrors(): boolean { - if ( + return ( this.exerciseInfo?.keyword === undefined || this.exerciseInfo?.language === undefined || this.exerciseInfo.creator === undefined || - !(this.exerciseInfo.keyword.length > 0) || - !(this.exerciseInfo.language.length > 0) || - !(this.exerciseInfo.creator.length > 0) || - (!this.isPublisherSameAsCreator && (this.exerciseInfo.publisher === undefined || !(this.exerciseInfo.publisher.length > 0))) - ) { - return true; - } - return false; + this.exerciseInfo.keyword.length <= 0 || + this.exerciseInfo.language.length <= 0 || + this.exerciseInfo.creator.length <= 0 || + (!this.isPublisherSameAsCreator && (this.exerciseInfo.publisher === undefined || this.exerciseInfo.publisher.length <= 0)) + ); } /** @@ -167,7 +175,7 @@ export class ExerciseImportComponent implements OnInit { * @param personList to add the given person to (ex. 'creators', 'publishers') */ addPerson(person: Person, personList: string): void { - if (this.validatePerson(person) && this.exerciseInfo) { + if (ExerciseImportComponent.validatePerson(person) && this.exerciseInfo) { if (!personList.localeCompare('creator')) { if (this.exerciseInfo.creator == null) { this.exerciseInfo.creator = []; @@ -206,22 +214,50 @@ export class ExerciseImportComponent implements OnInit { * @param person to check * @private */ - private validatePerson(person: Person): boolean { + private static validatePerson(person: Person): boolean { if (person.name === undefined || person.affiliation === undefined || person.email === undefined) { return false; } - if (person.name === '' || person.affiliation === '' || person.email === '') { - return false; + return !(person.name === '' || person.affiliation === '' || person.email === ''); + } + + /** + * Validates the provided metadata on server side. + * Returns a string representing the specific error, + * or and empty string in case of no error + * @private + */ + private validateMetadata(): Promise<string> { + if (!this.exerciseInfo) { + return new Promise<string>(() => 'exercise.import.error.invalidMetadata'); + } + return firstValueFrom(this.exerciseService.validateExerciseInfo(this.exerciseInfo)).catch(err => { + return err.error as string; + }); + } + + /** + * Function applying default preprocessing changes to the user provided + * metadata fields + * @private + */ + private applyPreprocessingStep() { + if (this.exerciseInfo!.difficulty !== null) { + this.exerciseInfo!.difficulty = this.exerciseInfo!.difficulty?.toLowerCase(); + } + if (this.isPublisherSameAsCreator) { + if (this.exerciseInfo) { + this.exerciseInfo.publisher = this.exerciseInfo?.creator; + } } - return true; } /** * Utility function used to turn words into title case * @param word the word to turn into title case */ - private toTitleCase(word: string): string { - return word[0].toUpperCase() + word.substr(1).toLowerCase(); + private static toTitleCase(word: string): string { + return word[0].toUpperCase() + word.substring(1).toLowerCase(); } /** diff --git a/src/main/webapp/app/exercise/service/exercise.service.ts b/src/main/webapp/app/exercise/service/exercise.service.ts index 34da3665aefdb9952516f36027d03004bbc3b47c..9a264eceb9689babfb86cb657462b345ed6f7a71 100644 --- a/src/main/webapp/app/exercise/service/exercise.service.ts +++ b/src/main/webapp/app/exercise/service/exercise.service.ts @@ -96,6 +96,14 @@ export class ExerciseService { ): Observable<ArtemisExerciseInfo> { return this.http.post<ArtemisExerciseInfo>(`${this.exerciseUrl}import-exercise/${exerciseToken}/${gitlabGroupId}`, exerciseInfo); } + + /** + * POST request validating the ExerciseInfo provided on the server side + * @param exerciseInfo to validate + */ + public validateExerciseInfo(exerciseInfo: ArtemisExerciseInfo): Observable<string> { + return this.http.post<string>(`${this.exerciseUrl}validate-exercise-info`, exerciseInfo); + } } /* diff --git a/src/main/webapp/app/shared/gitlab/gitlab-path-selector/gitlab-path-selector.component.html b/src/main/webapp/app/shared/gitlab/gitlab-path-selector/gitlab-path-selector.component.html index 7cc4db1adce563c2257921700ecc41fb939a8eda..b3b2f8a8dac7fcfc7f563573515853c6d83f92b6 100644 --- a/src/main/webapp/app/shared/gitlab/gitlab-path-selector/gitlab-path-selector.component.html +++ b/src/main/webapp/app/shared/gitlab/gitlab-path-selector/gitlab-path-selector.component.html @@ -50,12 +50,12 @@ </div> </div> <div *ngIf="isLoading"> - <div class="h-100 w-100 d-flex justify-content-center align-items-center m-5"> + <div class="d-flex justify-content-center align-items-center m-5"> <div class="spinner"></div> </div> </div> <div *ngIf="errorOccurred"> - <div class="h-100 w-100 d-flex justify-content-center align-items-center m-5"> + <div class="d-flex justify-content-center align-items-center m-5"> <strong jhiTranslate="gitlab.pathSelector.errorOccurred"></strong> </div> </div> diff --git a/src/main/webapp/i18n/de/exercise.json b/src/main/webapp/i18n/de/exercise.json index a0c9755c3b63bad5d5548c9845762c84cc7c54c1..e8e295c0ce10fdd69487c6eaf0e7105a0e2444a9 100644 --- a/src/main/webapp/i18n/de/exercise.json +++ b/src/main/webapp/i18n/de/exercise.json @@ -58,6 +58,7 @@ "medium": "Mittel", "difficult": "Schwierig", "advanced": "Schwierig", + "none": "Keine", "educationalLevel": { "singular": "Bildungsniveau", "plural": "Bildungsniveau" @@ -146,6 +147,15 @@ "submit": "Speichern" }, "error": { + "unauthorized": "Es ist ein Autorisierungsfehler aufgetreten. Sind Sie eingeloggt?", + "notFound": "Diese Aufgabe konnte nicht mehr gefunden werden.", + "loadingError": "Beim Laden der importierten Aufgabe ist ein Fehler aufgetreten.", + "retry": "Bitte versuchen Sie erneut, die Übung zu importieren", + "invalidMetadata": "Ungültige Metadaten. Bitte überprüfen Sie Ihre Eingaben.", + "invalidCreator": "Die angegebenen <strong>Erstellerinformationen</strong> sind ungültig", + "invalidPublisher": "Die angegebenen <strong>Herausgeber</strong> Daten sind ungültig", + "invalidKeyword": "Eines oder mehrere der angegebenen <strong>Schlüsselwörter</strong> sind ungültig", + "invalidProgrammingLanguage": "Eines oder mehrere der angegebenen <strong>Programmiersprachen</strong> sind ungültig", "creationFailed": "Beim Erstellen des neuen Gitlab-Projekts ist ein Fehler aufgetreten. Bitte stellen Sie sicher, dass Sie über ausreichende Berechtigungen für die gewählte Gruppe verfügen.", "checkoutFailed": "Das Git-Repository konnte nicht ausgecheckt werden. Bitte stellen Sie sicher, dass Sie über ausreichende Berechtigungen für die gewählte Gruppe verfügen.", "pushFailed": "Beim Committing und Pushing der Daten in das neue Git-Repository ist ein Fehler aufgetreten. Bitte stellen Sie sicher, dass Sie über ausreichende Berechtigungen für die gewählte Gruppe verfügen." diff --git a/src/main/webapp/i18n/en/exercise.json b/src/main/webapp/i18n/en/exercise.json index 019cddd2774f8ea17427c77411f8e7ca0049f16d..2c635b7066889c396fb1db43babcb57f11f4ed63 100644 --- a/src/main/webapp/i18n/en/exercise.json +++ b/src/main/webapp/i18n/en/exercise.json @@ -58,6 +58,7 @@ "medium": "Medium", "difficult": "Difficult", "advanced": "Difficult", + "none": "None", "educationalLevel": { "singular": "Educational Level", "plural": "Educational Level" @@ -146,6 +147,15 @@ "submit": "Submit" }, "error": { + "unauthorized": "An authorization error occurred. Are you logged in?", + "notFound": "This exercise couldn't be found anymore.", + "loadingError": "An error occurred while loading the imported exercise", + "retry": "Please retry to import the exercise", + "invalidMetadata": "Invalid metadata. Please check your inputs.", + "invalidCreator": "The provided <strong>creator's information</strong> is invalid", + "invalidPublisher": "The provided <strong>publisher's information</strong> is invalid", + "invalidKeyword": "One or more of the provided <strong>keywords</strong> are invalid", + "invalidProgrammingLanguage": "One or more of the provided <strong>programming languages</strong> are invalid", "creationFailed": "An error occurred while creating the new Gitlab Project. Please make sure to have sufficient permissions for the chosen group.", "checkoutFailed": "Unable to checkout the Git repository. Please make sure to have sufficient permissions for the chosen group.", "pushFailed": "An error occurred while committing and pushing the data to the new Git repository. Please make sure to have sufficient permissions for the chosen group." diff --git a/src/test/java/at/ac/uibk/gitsearch/service/ExerciseImportServiceIT.java b/src/test/java/at/ac/uibk/gitsearch/service/ExerciseImportServiceIT.java new file mode 100644 index 0000000000000000000000000000000000000000..15383c40b372c62dce0893bb9a6d450e08c3c848 --- /dev/null +++ b/src/test/java/at/ac/uibk/gitsearch/service/ExerciseImportServiceIT.java @@ -0,0 +1,300 @@ +package at.ac.uibk.gitsearch.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import at.ac.uibk.gitsearch.GitsearchApp; +import at.ac.uibk.gitsearch.es.model.ArtemisExerciseInfo; +import at.ac.uibk.gitsearch.service.dto.MetadataUserDTO; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystemException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; +import javax.ws.rs.client.Client; +import org.apache.commons.io.FileUtils; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.storage.file.FileRepositoryBuilder; +import org.gitlab4j.api.models.Group; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.MockitoAnnotations; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +@SpringBootTest(classes = GitsearchApp.class) +@RunWith(SpringJUnit4ClassRunner.class) +@WithMockUser(value = "test", authorities = "sharing") +public class ExerciseImportServiceIT { + + private final Logger log = LoggerFactory.getLogger(ExerciseImportServiceIT.class); + + @InjectMocks + @Autowired + ExerciseImportService exerciseImportService; + + @MockBean + GitlabService gitlabService; + + String token = "testToken"; + + String TMP_DIR_PROPERTY = "java.io.tmpdir"; + + Path testZipPath = Paths.get(System.getProperty(TMP_DIR_PROPERTY), "testZip.zip"); + + ArtemisExerciseInfo preliminaryArtemisExerciseInfo = new ArtemisExerciseInfo(); + + ArtemisExerciseInfo fullArtemisExerciseInfo = new ArtemisExerciseInfo(); + + @Before + public void init() throws IOException { + MockitoAnnotations.openMocks(this); + + preliminaryArtemisExerciseInfo.setMetadataVersion("1"); + preliminaryArtemisExerciseInfo.setTitle("TestTitle"); + preliminaryArtemisExerciseInfo.setLicense("testLicense"); + preliminaryArtemisExerciseInfo.setLearningResourceType("artemis"); + preliminaryArtemisExerciseInfo.setStructure("atomic"); + preliminaryArtemisExerciseInfo.setFormat(List.of("artemis")); + + MetadataUserDTO metadataUser = new MetadataUserDTO(); + metadataUser.setName("Test User"); + metadataUser.setAffiliation("Test Affiliation"); + metadataUser.setEmail("test@user"); + + fullArtemisExerciseInfo.setMetadataVersion("1"); + fullArtemisExerciseInfo.setTitle("TestTitle"); + fullArtemisExerciseInfo.setLicense("testLicense"); + fullArtemisExerciseInfo.setLearningResourceType("artemis"); + fullArtemisExerciseInfo.setStructure("atomic"); + fullArtemisExerciseInfo.setFormat(List.of("artemis")); + fullArtemisExerciseInfo.setKeyword(List.of("keyword")); + fullArtemisExerciseInfo.setDescription("TestDescription"); + fullArtemisExerciseInfo.setLanguage(List.of("en")); + fullArtemisExerciseInfo.setCreator(List.of(metadataUser)); + fullArtemisExerciseInfo.setPublisher(List.of(metadataUser)); + fullArtemisExerciseInfo.setIdentifier("https://test:8080/test/path/url"); + fullArtemisExerciseInfo.setProgrammingLanguage(List.of("C")); + } + + @After + public void cleanUp() throws IOException { + if (new File(System.getProperty("java.io.tmpdir"), token).exists()) { + FileUtils.deleteDirectory(new File(System.getProperty("java.io.tmpdir") + token)); + } + try { + Files.deleteIfExists(testZipPath); + } catch (FileSystemException fse) { + log.warn("Couldn't delete {}, if the problem persists, please remove this file manually", testZipPath.toAbsolutePath()); + } + } + + @Test + public void testImportExercise() throws Exception { + int gitlabGroup = 1; + + File metadata = Paths.get(System.getProperty(TMP_DIR_PROPERTY), token, "Exercise-Details-something.json").toFile(); + File problemStatement = Paths.get(System.getProperty(TMP_DIR_PROPERTY), token, "Problem-Statement-something.md").toFile(); + + metadata.getParentFile().mkdirs(); + metadata.createNewFile(); + problemStatement.createNewFile(); + Group group = new Group(); + Repository jgitRepo = FileRepositoryBuilder.create(Paths.get(System.getProperty("java.io.tmpdir"), token, ".git").toFile()); + + // mocks + doNothing().when(gitlabService).setJGitDefaultAuth(); + doReturn(group).when(gitlabService).getGroupById(gitlabGroup); + doReturn("repoUrl").when(gitlabService).createRepository(group, fullArtemisExerciseInfo.getTitle()); + doReturn(jgitRepo).when(gitlabService).checkoutRepo("repoUrl"); + doNothing().when(gitlabService).commitAndPushToRepo(jgitRepo, new File(System.getProperty("java.io.tmpdir"), token)); + + exerciseImportService.importExercise(fullArtemisExerciseInfo, token, gitlabGroup); + + // asserts + verify(gitlabService, times(1)).createRepository(group, preliminaryArtemisExerciseInfo.getTitle()); + verify(gitlabService, times(1)).commitAndPushToRepo(jgitRepo, new File(System.getProperty("java.io.tmpdir"), token)); + } + + @Test + public void testApplyExerciseInfoChanges() throws Exception { + File metadata = Paths.get(System.getProperty("java.io.tmpdir"), token, "metadata.yaml").toFile(); + metadata.getParentFile().mkdirs(); + metadata.createNewFile(); + + exerciseImportService.applyExerciseInfoChanges(fullArtemisExerciseInfo, token); + + Assert.assertTrue(new String(Files.readAllBytes(metadata.toPath())).contains("learningResourceType: \"artemis\"")); + Assert.assertTrue(new String(Files.readAllBytes(metadata.toPath())).contains("license: \"testLicense\"")); + Assert.assertTrue(new String(Files.readAllBytes(metadata.toPath())).contains("metadataVersion: \"1\"")); + Assert.assertTrue(new String(Files.readAllBytes(metadata.toPath())).contains("title: \"TestTitle\"")); + Assert.assertTrue(new String(Files.readAllBytes(metadata.toPath())).contains("description: \"TestDescription\"")); + Assert.assertTrue(new String(Files.readAllBytes(metadata.toPath())).contains("language:\n - \"en\"")); + Assert.assertTrue(new String(Files.readAllBytes(metadata.toPath())).contains("creator:\n -\n name: \"Test User\"")); + } + + @Test + public void testGetArtemisExerciseInfo() throws Exception { + File metadata = Paths.get(System.getProperty("java.io.tmpdir"), token, "Exercise-Details-something.json").toFile(); + metadata.getParentFile().mkdirs(); + metadata.createNewFile(); + FileWriter writer = new FileWriter(metadata, StandardCharsets.UTF_8); + writer.write( + "{\n" + + " \"metadataVersion\": \"1\",\n" + + " \"type\": \"programming exercise\",\n" + + " \"title\": \"TestTitle\",\n" + + " \"license\": \"testLicense\",\n" + + " \"maxPoints\": 100,\n" + + " \"bonusPoints\": 100,\n" + + " \"mode\": \"INDIVIDUAL\",\n" + + " \"staticCodeAnalysis\": false,\n" + + " \"allowOfflineIDE\": false,\n" + + " \"allowOnlineEditor\": false,\n" + + " \"showTestNamesToStudents\": false,\n" + + " \"sequentialTestRuns\": false\n" + + "}" + ); + writer.close(); + + preliminaryArtemisExerciseInfo.setMaxPoints(100f); + preliminaryArtemisExerciseInfo.setBonusPoints(100f); + preliminaryArtemisExerciseInfo.setMode(ArtemisExerciseInfo.ExerciseMode.INDIVIDUAL); + preliminaryArtemisExerciseInfo.setStaticCodeAnalysis(false); + preliminaryArtemisExerciseInfo.setAllowOfflineIDE(false); + preliminaryArtemisExerciseInfo.setAllowOnlineEditor(false); + preliminaryArtemisExerciseInfo.setShowTestNamesToStudents(false); + preliminaryArtemisExerciseInfo.setSequentialTestRuns(false); + preliminaryArtemisExerciseInfo.setStructure("atomic"); + + Assert.assertEquals(preliminaryArtemisExerciseInfo, exerciseImportService.getArtemisExerciseInfo(token)); + } + + @Test + @SuppressWarnings("PMD") + public void testGetArtemisExerciseInfo_onlyMetadata() throws Exception { + File metadata = Paths.get(System.getProperty("java.io.tmpdir"), token, "Exercise-Details-something.json").toFile(); + metadata.getParentFile().mkdirs(); + metadata.createNewFile(); + FileWriter writer = new FileWriter(metadata, StandardCharsets.UTF_8); + writer.write( + "{\n" + + " \"metadataVersion\": \"1\",\n" + + " \"type\": \"programming exercise\",\n" + + " \"title\": \"TestTitle\",\n" + + " \"license\": \"testLicense\"\n" + + "}" + ); + writer.close(); + + Assert.assertEquals(preliminaryArtemisExerciseInfo, exerciseImportService.getArtemisExerciseInfo(token)); + } + + @Test + public void testValidate() throws Exception { + String testApiKeyCorrect = "2c8845a4-b3df-414b-a682-36e2313dc1c0"; + String testApiKeyIncorrect = "wrong-apikey"; + + Assert.assertTrue(exerciseImportService.validate(testApiKeyCorrect)); + Assert.assertFalse(exerciseImportService.validate(testApiKeyIncorrect)); + } + + @Test + public void testValidateExerciseInfo_correct() throws Exception { + // Should not throw any Exception + exerciseImportService.validateExerciseInfo(fullArtemisExerciseInfo); + } + + @Test(expected = ArtemisImportError.class) + public void testValidateExerciseInfo_missing() throws Exception { + exerciseImportService.validateExerciseInfo(preliminaryArtemisExerciseInfo); + } + + @Test + public void testSanitizeExerciseInfo() throws Exception { + int gitlabGroup = 1; + + File metadata = Paths.get(System.getProperty("java.io.tmpdir"), token, "Exercise-Details-something.json").toFile(); + metadata.getParentFile().mkdirs(); + metadata.createNewFile(); + Group group = new Group(); + Repository jgitRepo = FileRepositoryBuilder.create(Paths.get(System.getProperty("java.io.tmpdir"), token, ".git").toFile()); + + // mocks + MetadataUserDTO user = new MetadataUserDTO(); + user.setName("<script>Name"); + user.setEmail("user@<script>test"); + user.setAffiliation("test<script>affiliation"); + + fullArtemisExerciseInfo.setMetadataVersion("<script>"); + fullArtemisExerciseInfo.setTitle(fullArtemisExerciseInfo.getTitle() + "<script>"); + fullArtemisExerciseInfo.setDescription(fullArtemisExerciseInfo.getDescription() + "<script>"); + fullArtemisExerciseInfo.setKeyword(List.of("Key<script>word")); + fullArtemisExerciseInfo.setCreator(List.of(user)); + fullArtemisExerciseInfo.setPublisher(List.of(user)); + fullArtemisExerciseInfo.setLicense(fullArtemisExerciseInfo.getLicense() + "<script>"); + fullArtemisExerciseInfo.setFormat(List.of("<script>")); + fullArtemisExerciseInfo.setLearningResourceType(fullArtemisExerciseInfo.getLearningResourceType() + "<script>"); + + doNothing().when(gitlabService).setJGitDefaultAuth(); + doReturn(group).when(gitlabService).getGroupById(gitlabGroup); + doReturn("repoUrl").when(gitlabService).createRepository(any(), anyString()); + doReturn(jgitRepo).when(gitlabService).checkoutRepo("repoUrl"); + doNothing().when(gitlabService).commitAndPushToRepo(jgitRepo, new File(System.getProperty("java.io.tmpdir"), token)); + + exerciseImportService.importExercise(fullArtemisExerciseInfo, token, gitlabGroup); + + Assert.assertFalse(fullArtemisExerciseInfo.getMetadataVersion().contains("<script")); + Assert.assertFalse(fullArtemisExerciseInfo.getTitle().contains("<script")); + Assert.assertFalse(fullArtemisExerciseInfo.getDescription().contains("<script")); + Assert.assertFalse(fullArtemisExerciseInfo.getKeyword().get(0).contains("<script")); + Assert.assertFalse(fullArtemisExerciseInfo.getCreator().get(0).getName().contains("<script")); + Assert.assertFalse(fullArtemisExerciseInfo.getCreator().get(0).getEmail().contains("<script")); + Assert.assertFalse(fullArtemisExerciseInfo.getCreator().get(0).getAffiliation().contains("<script")); + Assert.assertFalse(fullArtemisExerciseInfo.getPublisher().get(0).getName().contains("<script")); + Assert.assertFalse(fullArtemisExerciseInfo.getPublisher().get(0).getEmail().contains("<script")); + Assert.assertFalse(fullArtemisExerciseInfo.getPublisher().get(0).getAffiliation().contains("<script")); + Assert.assertFalse(fullArtemisExerciseInfo.getLicense().contains("<script")); + Assert.assertFalse(fullArtemisExerciseInfo.getFormat().contains("<script")); + Assert.assertFalse(fullArtemisExerciseInfo.getLearningResourceType().contains("<script")); + } + + @Test + public void testImportExerciseFromArtemis() throws Exception { + ExerciseImportService mockExerciseImportService = spy(exerciseImportService); + + ZipOutputStream out = new ZipOutputStream(new FileOutputStream(testZipPath.toFile())); + out.putNextEntry(new ZipEntry("testFile.yml")); + byte[] buffer = new byte[1024]; + + int length; + FileInputStream fis = new FileInputStream(testZipPath.toFile()); + while ((length = fis.read(buffer)) >= 0) { + out.write(buffer, 0, length); + } + out.closeEntry(); + fis.close(); + + doReturn(new ZipInputStream(new FileInputStream(testZipPath.toFile()))) + .when(mockExerciseImportService) + .getZipInputStreamFromUrl(anyString()); + mockExerciseImportService.importExerciseFromArtemis(token); + + Assert.assertTrue(Paths.get(System.getProperty(TMP_DIR_PROPERTY), token).toFile().exists()); + } +} diff --git a/src/test/java/at/ac/uibk/gitsearch/service/ExerciseServiceIT.java b/src/test/java/at/ac/uibk/gitsearch/service/ExerciseServiceIT.java deleted file mode 100644 index e3941b38149d74c1f37bb1e26655e39d6bd6386f..0000000000000000000000000000000000000000 --- a/src/test/java/at/ac/uibk/gitsearch/service/ExerciseServiceIT.java +++ /dev/null @@ -1,182 +0,0 @@ -package at.ac.uibk.gitsearch.service; - -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -import at.ac.uibk.gitsearch.GitsearchApp; -import at.ac.uibk.gitsearch.es.model.ArtemisExerciseInfo; -import java.io.File; -import java.io.FileWriter; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.List; -import org.apache.commons.io.FileUtils; -import org.eclipse.jgit.lib.Repository; -import org.eclipse.jgit.storage.file.FileRepositoryBuilder; -import org.gitlab4j.api.models.Group; -import org.junit.After; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.InjectMocks; -import org.mockito.MockitoAnnotations; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; - -@SpringBootTest(classes = GitsearchApp.class) -@RunWith(SpringJUnit4ClassRunner.class) -@WithMockUser(value = "test", authorities = "sharing") -public class ExerciseServiceIT { - - @InjectMocks - @Autowired - ExerciseService exerciseService; - - @MockBean - GitlabService gitlabService; - - String token = "testToken"; - - ArtemisExerciseInfo artemisExerciseInfo = new ArtemisExerciseInfo(); - - @Before - public void init() throws IOException { - MockitoAnnotations.openMocks(this); - artemisExerciseInfo.setMetadataVersion("1"); - artemisExerciseInfo.setLearningResourceType("Artemis"); - artemisExerciseInfo.setTitle("TestTitle"); - artemisExerciseInfo.setLicense("testLicense"); - } - - @After - public void cleanUp() throws IOException { - if (new File(System.getProperty("java.io.tmpdir"), token).exists()) { - FileUtils.deleteDirectory(new File(System.getProperty("java.io.tmpdir") + token)); - } - } - - @Test - public void testImportExercise() throws Exception { - int gitlabGroup = 1; - - File metadata = Paths.get(System.getProperty("java.io.tmpdir"), token, "Exercise-Details-something.json").toFile(); - metadata.getParentFile().mkdirs(); - metadata.createNewFile(); - Group group = new Group(); - Repository jgitRepo = FileRepositoryBuilder.create(Paths.get(System.getProperty("java.io.tmpdir"), token, ".git").toFile()); - - // mocks - doNothing().when(gitlabService).setJGitDefaultAuth(); - doReturn(group).when(gitlabService).getGroupById(gitlabGroup); - doReturn("repoUrl").when(gitlabService).createRepository(group, artemisExerciseInfo.getTitle()); - doReturn(jgitRepo).when(gitlabService).checkoutRepo("repoUrl"); - doNothing().when(gitlabService).commitAndPushToRepo(jgitRepo, new File(System.getProperty("java.io.tmpdir"), token)); - - exerciseService.importExercise(artemisExerciseInfo, token, gitlabGroup); - - // asserts - verify(gitlabService, times(1)).createRepository(group, artemisExerciseInfo.getTitle()); - verify(gitlabService, times(1)).commitAndPushToRepo(jgitRepo, new File(System.getProperty("java.io.tmpdir"), token)); - } - - @Test - public void testApplyExerciseInfoChanges() throws Exception { - File metadata = Paths.get(System.getProperty("java.io.tmpdir"), token, "metadata.yaml").toFile(); - metadata.getParentFile().mkdirs(); - metadata.createNewFile(); - - exerciseService.applyExerciseInfoChanges(artemisExerciseInfo, token); - - Assert.assertEquals( - "learningResourceType: \"Artemis\"\n" + "license: \"testLicense\"\n" + "metadataVersion: \"1\"\n" + "title: \"TestTitle\"", - new String(Files.readAllBytes(metadata.toPath())).strip() - ); - } - - @Test - public void testGetArtemisExerciseInfo() throws Exception { - File metadata = Paths.get(System.getProperty("java.io.tmpdir"), token, "Exercise-Details-something.json").toFile(); - metadata.getParentFile().mkdirs(); - metadata.createNewFile(); - FileWriter writer = new FileWriter(metadata, StandardCharsets.UTF_8); - writer.write( - "{\n" + - " \"metadataVersion\": \"1\",\n" + - " \"type\": \"programming exercise\",\n" + - " \"title\": \"TestTitle\",\n" + - " \"license\": \"testLicense\",\n" + - " \"maxPoints\": 100,\n" + - " \"bonusPoints\": 100,\n" + - " \"mode\": \"INDIVIDUAL\",\n" + - " \"staticCodeAnalysis\": false,\n" + - " \"allowOfflineIDE\": false,\n" + - " \"allowOnlineEditor\": false,\n" + - " \"showTestNamesToStudents\": false,\n" + - " \"sequentialTestRuns\": false\n" + - "}" - ); - writer.close(); - - artemisExerciseInfo.setMaxPoints(100f); - artemisExerciseInfo.setBonusPoints(100f); - artemisExerciseInfo.setMode(ArtemisExerciseInfo.ExerciseMode.INDIVIDUAL); - artemisExerciseInfo.setStaticCodeAnalysis(false); - artemisExerciseInfo.setAllowOfflineIDE(false); - artemisExerciseInfo.setAllowOnlineEditor(false); - artemisExerciseInfo.setShowTestNamesToStudents(false); - artemisExerciseInfo.setSequentialTestRuns(false); - artemisExerciseInfo.setLearningResourceType(null); - artemisExerciseInfo.setStructure("atomic"); - List<String> str = new ArrayList<>(); - str.add("artemis"); - - artemisExerciseInfo.setFormat(str); - - Assert.assertEquals(artemisExerciseInfo, exerciseService.getArtemisExerciseInfo(token)); - } - - @Test - @SuppressWarnings("PMD") - public void testGetArtemisExerciseInfo_onlyMetadata() throws Exception { - File metadata = Paths.get(System.getProperty("java.io.tmpdir"), token, "Exercise-Details-something.json").toFile(); - metadata.getParentFile().mkdirs(); - metadata.createNewFile(); - FileWriter writer = new FileWriter(metadata, StandardCharsets.UTF_8); - writer.write( - "{\n" + - " \"metadataVersion\": \"1\",\n" + - " \"type\": \"programming exercise\",\n" + - " \"title\": \"TestTitle\",\n" + - " \"license\": \"testLicense\"\n" + - "}" - ); - writer.close(); - - artemisExerciseInfo.setLearningResourceType(null); - artemisExerciseInfo.setStructure("atomic"); - List<String> str = new ArrayList<>(); - str.add("artemis"); - - artemisExerciseInfo.setFormat(str); - - Assert.assertEquals(artemisExerciseInfo, exerciseService.getArtemisExerciseInfo(token)); - } - - @Test - public void testValidate() throws Exception { - String testApiKeyCorrect = "2c8845a4-b3df-414b-a682-36e2313dc1c0"; - String testApiKeyIncorrect = "wrong-apikey"; - - Assert.assertTrue(exerciseService.validate(testApiKeyCorrect)); - Assert.assertFalse(exerciseService.validate(testApiKeyIncorrect)); - } -} diff --git a/src/test/javascript/spec/app/exercise/import/exercise-import.component.spec.ts b/src/test/javascript/spec/app/exercise/import/exercise-import.component.spec.ts deleted file mode 100644 index d6b7b461006f6f5a02649720043e55dc093e7f30..0000000000000000000000000000000000000000 --- a/src/test/javascript/spec/app/exercise/import/exercise-import.component.spec.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { HttpClient, HttpHandler } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; -import { ExerciseImportComponent } from 'app/exercise/import/exercise-import.component'; -import { ExerciseService } from 'app/exercise/service/exercise.service'; -import { ArtemisExerciseInfo } from 'app/shared/model/artemis-exercise-info.model'; -import { IInteractivityType } from 'app/shared/model/exercise.model'; -import { GitlabGroup, IVisibility } from 'app/shared/model/gitlab-group.model'; -import { SearchResultDTO } from 'app/shared/model/search/search-result-dto.model'; -import { of } from 'rxjs'; - -describe('Component Tests', () => { - describe('Exercise Import Component', () => { - let comp: ExerciseImportComponent; - let fixture: ComponentFixture<ExerciseImportComponent>; - let service: ExerciseService; - - const exerciseInfoTemplate = new ArtemisExerciseInfo( - [], - '', - [], - [], - false, - '', - 0, - '', - '', - [], - [], - [], - '', - '', - '', - {} as IInteractivityType, - [], - [], - [], - new Date(), - '', - '', - '', - {} as SearchResultDTO, - ['Java'], - '', - [], - 0, - [], - '', - '', - [], - [], - '', - 'testExTitle', - '', - '', - 0, - 0, - 0, - false - ); - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [], - declarations: [ExerciseImportComponent], - providers: [ - { - provide: ActivatedRoute, - useValue: { - snapshot: { - params: { - token: 'exercise-test-token', - }, - queryParams: { - callback: 'https://callback-url.test', - }, - }, - }, - }, - ExerciseImportComponent, - ExerciseService, - HttpClient, - HttpHandler, - ], - }) - .overrideTemplate(ExerciseImportComponent, '') - .compileComponents(); - - fixture = TestBed.createComponent(ExerciseImportComponent); - comp = fixture.componentInstance; - service = TestBed.get(ExerciseService); - }); - - it('Should load on init', () => { - // GIVEN - spyOn(service, 'importExerciseInfoFromArtemis').and.returnValue(of(exerciseInfoTemplate)); - - // WHEN - comp.ngOnInit(); - - // THEN - expect(comp.exerciseInfo?.title).toEqual('testExTitle'); - expect(comp.exerciseInfo?.programmingLanguage[0]).toEqual('Java'); - }); - - it('Should map metadata values when importing', () => { - // GIVEN - comp.exerciseToken = 'TestToken'; - comp.exerciseInfo = exerciseInfoTemplate; - comp.exerciseInfo.difficulty = 'ADVANCED'; - comp.isPublisherSameAsCreator = true; - - const group: GitlabGroup = { - id: 1, - name: 'testGroup', - path: 'testPath', - description: '', - visibility: IVisibility.PUBLIC, - avatarUrl: '', - webUrl: '', - fullName: '', - fullPath: '', - parentId: 0, - }; - spyOn(comp, 'openGitlabPathSelectorModal').and.returnValue( - new Promise(resolve => { - resolve(group); - }) - ); - spyOn(service, 'submitExerciseInfoForImport').and.returnValue(of({})); - - // WHEN - comp.import(); - - // THEN - expect(comp.exerciseInfo.difficulty).toEqual(comp.exerciseInfo.difficulty.toLowerCase()); - expect(comp.exerciseInfo.publisher).toEqual(comp.exerciseInfo.creator); - }); - - it('Should add and remove creators from list', () => { - // GIVEN - comp.exerciseInfo = exerciseInfoTemplate; - - const newPerson = { name: 'newPerson', email: 'person@email', affiliation: 'newAffiliation' }; - const newPersonCopy = { name: 'newPerson', email: 'person@email', affiliation: 'newAffiliation' }; - const personToRemove = { name: 'personToRemove', email: 'person@toremove', affiliation: '' }; - - // WHEN - comp.addPerson(newPerson, 'creator'); - comp.addPerson(personToRemove, 'creator'); - comp.removePerson(personToRemove, 'creator'); - - // THEN - expect(comp.exerciseInfo?.creator).toEqual([newPersonCopy]); - }); - - it('Should validate form button groups', () => { - // GIVEN - comp.exerciseInfo = exerciseInfoTemplate; - comp.exerciseInfo.keyword = []; - comp.exerciseInfo.language = []; - const resultWithErrors = comp.hasValidationErrors(); - - comp.exerciseInfo.keyword.push('keyword'); - comp.setLanguage('english'); - const resultWithoutErrors = comp.hasValidationErrors(); - - // THEN - expect(resultWithErrors).toEqual(true); - expect(resultWithoutErrors).toEqual(false); - }); - }); -});