diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java b/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java index 914bb6c074ea..0ae5d62368c2 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java @@ -43,6 +43,8 @@ import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.repository.UserRepository; import de.tum.cit.aet.artemis.core.security.Role; +import de.tum.cit.aet.artemis.core.security.allowedTools.AllowedTools; +import de.tum.cit.aet.artemis.core.security.allowedTools.ToolTokenType; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastInstructor; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastTutor; @@ -171,6 +173,7 @@ public ResponseEntity getResult(@PathVariable Long participationId, @Pat */ @GetMapping("participations/{participationId}/results/{resultId}/details") @EnforceAtLeastStudent + @AllowedTools(ToolTokenType.SCORPIO) public ResponseEntity> getResultDetails(@PathVariable Long participationId, @PathVariable Long resultId) { log.debug("REST request to get details of Result : {}", resultId); Result result = resultRepository.findByIdWithEagerFeedbacksElseThrow(resultId); diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/web/ExerciseResource.java b/src/main/java/de/tum/cit/aet/artemis/exercise/web/ExerciseResource.java index b6bbc377b486..746d22319bac 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/web/ExerciseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/web/ExerciseResource.java @@ -314,6 +314,7 @@ public ResponseEntity reset(@PathVariable Long exerciseId) { */ @GetMapping("exercises/{exerciseId}/details") @EnforceAtLeastStudent + @AllowedTools(ToolTokenType.SCORPIO) public ResponseEntity getExerciseDetails(@PathVariable Long exerciseId) { User user = userRepository.getUserWithGroupsAndAuthorities(); Exercise exercise = exerciseService.findOneWithDetailsForStudents(exerciseId, user); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/domain/VcsRepositoryUri.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/VcsRepositoryUri.java index cf09db415720..21a6b603a50f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/domain/VcsRepositoryUri.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/domain/VcsRepositoryUri.java @@ -4,6 +4,8 @@ import java.net.URISyntaxException; import java.util.Objects; +import org.springframework.web.util.UriComponentsBuilder; + /** * Represents a Version Control System (VCS) repository URI with capabilities to manipulate and extract information from it. * This class supports handling both local file references and remote repository URIs. @@ -33,6 +35,22 @@ public VcsRepositoryUri(String uriSpecString) throws URISyntaxException { this.uri = new URI(uriSpecString); } + /** + * Initializes a new instance of the {@link VcsRepositoryUri} class from a repository name + * and builds an url to format {server.url}/git/{project-key}/{repo-name}.git with repo-name consisting of {project-key}-{repo-type} + * + * @param vcBaseUrl The base URL of the version control system + * @param repositoryName containing the project key at the beginning + */ + public VcsRepositoryUri(String vcBaseUrl, String repositoryName) throws URISyntaxException { + if (!repositoryName.matches("[a-zA-Z0-9]+-[a-zA-Z0-9-]+")) { + throw new IllegalArgumentException("Repository name must be in the format -"); + } + + var projectKey = repositoryName.split("-")[0]; + this.uri = UriComponentsBuilder.fromUriString(vcBaseUrl).pathSegment("git", projectKey.toUpperCase(), repositoryName + ".git").build().toUri(); + } + /** * Initializes a new instance of the {@link VcsRepositoryUri} class from a file reference, e.g. C:/Users/Admin/AppData/Local/Temp/studentOriginRepo1644180397872264950 * The file's URI is extracted and stored. diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/dto/RepoNameProgrammingStudentParticipationDTO.java b/src/main/java/de/tum/cit/aet/artemis/programming/dto/RepoNameProgrammingStudentParticipationDTO.java new file mode 100644 index 000000000000..56efb2d85f73 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/dto/RepoNameProgrammingStudentParticipationDTO.java @@ -0,0 +1,79 @@ +package de.tum.cit.aet.artemis.programming.dto; + +import java.time.ZonedDateTime; +import java.util.Optional; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.assessment.domain.AssessmentType; +import de.tum.cit.aet.artemis.core.domain.Course; +import de.tum.cit.aet.artemis.exercise.domain.DifficultyLevel; +import de.tum.cit.aet.artemis.exercise.domain.ExerciseMode; +import de.tum.cit.aet.artemis.exercise.domain.ExerciseType; +import de.tum.cit.aet.artemis.exercise.domain.IncludedInOverallScore; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseStudentParticipation; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage; + +/** + * DTO for the endpoint + * {@link de.tum.cit.aet.artemis.programming.web.ProgrammingExerciseParticipationResource#getStudentParticipationByRepoName(String)} + * constructing a participation DTO including exercise and course information + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record RepoNameProgrammingStudentParticipationDTO(long id, ZonedDateTime individualDueDate, String participantName, String participantIdentifier, String repositoryUri, + String buildPlanId, String branch, RepoNameProgrammingExerciseDTO exercise) { + + /** + * Converts a ProgrammingExerciseStudentParticipation into a dto for the endpoint + * {@link de.tum.cit.aet.artemis.programming.web.ProgrammingExerciseParticipationResource#getStudentParticipationByRepoName(String)}. + * + * @param participation to convert + * @return the converted DTO + */ + public static RepoNameProgrammingStudentParticipationDTO of(ProgrammingExerciseStudentParticipation participation) { + return Optional + .ofNullable(participation).map(p -> new RepoNameProgrammingStudentParticipationDTO(p.getId(), p.getIndividualDueDate(), p.getParticipantName(), + p.getParticipantIdentifier(), p.getRepositoryUri(), p.getBuildPlanId(), p.getBranch(), RepoNameProgrammingExerciseDTO.of(p.getProgrammingExercise()))) + .orElse(null); + } + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + public record RepoNameProgrammingExerciseDTO(long id, String problemStatement, String title, String shortName, ZonedDateTime releaseDate, ZonedDateTime startDate, + ZonedDateTime dueDate, ZonedDateTime assessmentDueDate, Double maxPoints, Double bonusPoints, AssessmentType assessmentType, + boolean allowComplaintsForAutomaticAssessments, boolean allowFeedbackRequests, DifficultyLevel difficulty, ExerciseMode mode, + IncludedInOverallScore includedInOverallScore, ExerciseType exerciseType, ZonedDateTime exampleSolutionPublicationDate, RepoNameCourseDTO course, String projectKey, + ProgrammingLanguage programmingLanguage, Boolean showTestNamesToStudents) { + + /** + * Converts a ProgrammingExercise into a dto for the endpoint + * {@link de.tum.cit.aet.artemis.programming.web.ProgrammingExerciseParticipationResource#getStudentParticipationByRepoName(String)}. + * + * @param exercise to convert + * @return the converted DTO + */ + public static RepoNameProgrammingExerciseDTO of(ProgrammingExercise exercise) { + return Optional.ofNullable(exercise) + .map(e -> new RepoNameProgrammingExerciseDTO(e.getId(), e.getProblemStatement(), e.getTitle(), e.getShortName(), e.getReleaseDate(), e.getStartDate(), + e.getDueDate(), e.getAssessmentDueDate(), e.getMaxPoints(), e.getBonusPoints(), e.getAssessmentType(), e.getAllowComplaintsForAutomaticAssessments(), + e.getAllowFeedbackRequests(), e.getDifficulty(), e.getMode(), e.getIncludedInOverallScore(), e.getExerciseType(), e.getExampleSolutionPublicationDate(), + RepoNameCourseDTO.of(e.getCourseViaExerciseGroupOrCourseMember()), e.getProjectKey(), e.getProgrammingLanguage(), e.getShowTestNamesToStudents())) + .orElse(null); + } + } + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + public record RepoNameCourseDTO(long id, String title, String shortName) { + + /** + * Converts a Course into a dto for the endpoint + * {@link de.tum.cit.aet.artemis.programming.web.ProgrammingExerciseParticipationResource#getStudentParticipationByRepoName(String)}. + * + * @param course to convert + * @return the converted DTO + */ + public static RepoNameCourseDTO of(Course course) { + return Optional.ofNullable(course).map(c -> new RepoNameCourseDTO(c.getId(), c.getTitle(), c.getShortName())).orElse(null); + } + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java index fa474fc1445c..3de319b252df 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java @@ -3,6 +3,7 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; import java.io.IOException; +import java.net.URISyntaxException; import java.time.ZonedDateTime; import java.util.List; import java.util.Map; @@ -14,9 +15,11 @@ import org.eclipse.jgit.api.errors.GitAPIException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Profile; import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PutMapping; @@ -32,6 +35,8 @@ import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; import de.tum.cit.aet.artemis.core.security.Role; +import de.tum.cit.aet.artemis.core.security.allowedTools.AllowedTools; +import de.tum.cit.aet.artemis.core.security.allowedTools.ToolTokenType; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastInstructor; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastTutor; @@ -53,6 +58,7 @@ import de.tum.cit.aet.artemis.programming.domain.VcsAccessLog; import de.tum.cit.aet.artemis.programming.domain.VcsRepositoryUri; import de.tum.cit.aet.artemis.programming.dto.CommitInfoDTO; +import de.tum.cit.aet.artemis.programming.dto.RepoNameProgrammingStudentParticipationDTO; import de.tum.cit.aet.artemis.programming.dto.VcsAccessLogDTO; import de.tum.cit.aet.artemis.programming.repository.AuxiliaryRepositoryRepository; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository; @@ -74,6 +80,9 @@ public class ProgrammingExerciseParticipationResource { private static final String ENTITY_NAME = "programmingExerciseParticipation"; + @Value("${artemis.version-control.url}") + private String localVCBaseUrl; + private final ParticipationRepository participationRepository; private final ProgrammingExerciseParticipationService programmingExerciseParticipationService; @@ -180,6 +189,49 @@ public ResponseEntity getParticipationW return ResponseEntity.ok(participation); } + /** + * Get the student participation by its repository identifier. + * The repository name is the last part of the repository URL. + * The repository URL is built as follows: {server.url}/git/{project_key}/{repo-name}.git with {repo-name} consisting of + * {project-key}-{repo-type} + * + * @param repoNameParam the URL repository identifier + * @return the ResponseEntity with status 200 (OK) and the participation DTO {@link de.tum.cit.aet.artemis.programming.dto.RepoNameProgrammingStudentParticipationDTO} in body, + * or with status 400 (Bad Request) if the repo name is not provided as request parameter, + * or with status 404 (Not Found) if the participation is not found, + * or with status 403 (Forbidden) if the user doesn't have access to the participation + */ + @GetMapping("programming-exercise-participations") + @EnforceAtLeastStudent + @AllowedTools(ToolTokenType.SCORPIO) + public ResponseEntity getStudentParticipationByRepoName(@RequestParam(required = true, name = "repoName") String repoNameParam) { + String repoUri; + if (!StringUtils.hasText(repoNameParam)) { + throw new BadRequestAlertException("Repository name must be provided", ENTITY_NAME, "repoNameRequired"); + } + + try { + repoUri = new VcsRepositoryUri(localVCBaseUrl, repoNameParam).toString(); + } + catch (URISyntaxException e) { + throw new BadRequestAlertException("Invalid repository URL", ENTITY_NAME, "invalidRepositoryUrl"); + } + catch (IllegalArgumentException e) { + throw new BadRequestAlertException("Invalid repository name", ENTITY_NAME, "invalidRepositoryName"); + } + + // find participation by url + var participation = programmingExerciseStudentParticipationRepository.findByRepositoryUriElseThrow(repoUri); + + participationAuthCheckService.checkCanAccessParticipationElseThrow(participation); + // check if the exercise is released. This also checks if the user can see an exam exercise + if (!participation.getProgrammingExercise().isReleased()) { + throw new AccessForbiddenException("exercise", participation.getProgrammingExercise().getId()); + } + + return ResponseEntity.ok(RepoNameProgrammingStudentParticipationDTO.of(participation)); + } + /** * Get the latest result for a given programming exercise participation including its result. * diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/ProgrammingExerciseParticipationIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/ProgrammingExerciseParticipationIntegrationTest.java index 6777d35d22fa..dda5865d99f5 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/ProgrammingExerciseParticipationIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/ProgrammingExerciseParticipationIntegrationTest.java @@ -8,13 +8,16 @@ import static org.mockito.Mockito.doThrow; import java.io.IOException; +import java.net.URI; import java.net.URISyntaxException; import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; +import java.util.UUID; import java.util.stream.Stream; import org.eclipse.jgit.api.errors.GitAPIException; @@ -50,6 +53,7 @@ import de.tum.cit.aet.artemis.programming.domain.TemplateProgrammingExerciseParticipation; import de.tum.cit.aet.artemis.programming.domain.VcsRepositoryUri; import de.tum.cit.aet.artemis.programming.dto.CommitInfoDTO; +import de.tum.cit.aet.artemis.programming.dto.RepoNameProgrammingStudentParticipationDTO; class ProgrammingExerciseParticipationIntegrationTest extends AbstractProgrammingIntegrationIndependentTest { @@ -645,6 +649,100 @@ void checkIfParticipationHasResult_withoutResult_returnsFalse() throws Exception assertThat(response).isFalse(); } + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testGetProgrammingExerciseStudentParticipationByRepoName() throws Exception { + programmingExercise.setReleaseDate(ZonedDateTime.now()); + programmingExercise = programmingExerciseRepository.save(programmingExercise); + + var participation = participationUtilService.addStudentParticipationForProgrammingExercise(programmingExercise, TEST_PREFIX + "student1"); + + var repoName = extractRepoName(participation.getRepositoryUri()); + RepoNameProgrammingStudentParticipationDTO participationDTO = request.get("/api/programming/programming-exercise-participations?repoName=" + repoName, HttpStatus.OK, + RepoNameProgrammingStudentParticipationDTO.class); + + assertThat(participationDTO.id()).isEqualTo(participation.getId()); + assertThat(participationDTO.exercise().id()).isEqualTo(participation.getExercise().getId()); + assertThat(participationDTO.exercise().course().id()).isEqualTo(participation.getExercise().getCourseViaExerciseGroupOrCourseMember().getId()); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testGetProgrammingExerciseStudentParticipationNoParam() throws Exception { + String body = request.get("/api/programming/programming-exercise-participations", HttpStatus.BAD_REQUEST, String.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testGetProgrammingExerciseStudentParticipationByRepoNameNotFound() throws Exception { + var participation = participationUtilService.addStudentParticipationForProgrammingExercise(programmingExercise, TEST_PREFIX + "student1"); + + // Generate a random URI that is not in the database + URI repoUrl; + Optional foundParticipation; + do { + repoUrl = new URI(participation.getRepositoryUri()); + repoUrl = new URI(repoUrl.getScheme(), repoUrl.getUserInfo(), repoUrl.getHost(), repoUrl.getPort(), "/" + UUID.randomUUID().toString(), repoUrl.getQuery(), + repoUrl.getFragment()); + foundParticipation = programmingExerciseStudentParticipationRepository.findByRepositoryUri(repoUrl.toString()); + } + while (foundParticipation.isPresent()); + + var repoName = extractRepoName(repoUrl.toString()); + String body = request.get("/api/programming/programming-exercise-participations?repoName=" + repoName, HttpStatus.NOT_FOUND, String.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testGetProgrammingExerciseStudentParticipationByInvalidRepoName() throws Exception { + var participation = participationUtilService.addStudentParticipationForProgrammingExercise(programmingExercise, TEST_PREFIX + "student1"); + + // Generate a random URI that is not in the database + URI repoUrl; + Optional foundParticipation; + do { + repoUrl = new URI(participation.getRepositoryUri()); + + // test a repoName which does not match the expected pattern of - + // generate random string without a dash + String invalidRepoName = UUID.randomUUID().toString().replace("-", ""); + repoUrl = new URI(repoUrl.getScheme(), repoUrl.getUserInfo(), repoUrl.getHost(), repoUrl.getPort(), "/" + invalidRepoName, repoUrl.getQuery(), repoUrl.getFragment()); + foundParticipation = programmingExerciseStudentParticipationRepository.findByRepositoryUri(repoUrl.toString()); + } + while (foundParticipation.isPresent()); + + var repoName = extractRepoName(repoUrl.toString()); + String body = request.get("/api/programming/programming-exercise-participations?repoName=" + repoName, HttpStatus.BAD_REQUEST, String.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testGetProgrammingExerciseStudentParticipationByRepoNameNotVisible() throws Exception { + var participation = participationUtilService.addStudentParticipationForProgrammingExercise(programmingExercise, TEST_PREFIX + "student2"); + + var repoName = extractRepoName(participation.getRepositoryUri()); + String body = request.get("/api/programming/programming-exercise-participations?repoName=" + repoName, HttpStatus.FORBIDDEN, String.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testGetProgrammingExerciseStudentParticipationByRepoNameExam() throws Exception { + var programmingExercise = programmingExerciseUtilService.addCourseExamExerciseGroupWithProgrammingExerciseAndExamDates(ZonedDateTime.now().plusHours(1), + ZonedDateTime.now().plusHours(2), ZonedDateTime.now().plusHours(3), ZonedDateTime.now().plusHours(4), TEST_PREFIX + "student1", 1000); + programmingExercise.setReleaseDate(ZonedDateTime.now()); + programmingExercise = programmingExerciseRepository.save(programmingExercise); + + var participation = participationUtilService.addStudentParticipationForProgrammingExercise(programmingExercise, TEST_PREFIX + "student1"); + + var repoName = extractRepoName(participation.getRepositoryUri()); + String body = request.get("/api/programming/programming-exercise-participations?repoName=" + repoName, HttpStatus.FORBIDDEN, String.class); + } + + String extractRepoName(String repoUrl) { + // /git//.git + return repoUrl.substring(repoUrl.lastIndexOf("/") + 1, repoUrl.length() - 4); + } + @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void checkResetRepository_noAccess_forbidden() throws Exception {