diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/domain/AuthenticationMechanism.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/AuthenticationMechanism.java new file mode 100644 index 000000000000..3caec801ed53 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/domain/AuthenticationMechanism.java @@ -0,0 +1,24 @@ +package de.tum.cit.aet.artemis.programming.domain; + +public enum AuthenticationMechanism { + /** + * The user used password to authenticate to the LocalVC + */ + PASSWORD, + /** + * The user used the participation+user token to authenticate to the LocalVC + */ + PARTICIPATION_VCS_ACCESS_TOKEN, + /** + * The user used the user token to authenticate to the LocalVC + */ + USER_VCS_ACCESS_TOKEN, + /** + * The user used SSH user token to authenticate to the LocalVC + */ + SSH, + /** + * The user used the artemis client code editor to authenticate to the LocalVC + */ + CODE_EDITOR +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/domain/VcsAccessLog.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/VcsAccessLog.java new file mode 100644 index 000000000000..560ca52a31c1 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/domain/VcsAccessLog.java @@ -0,0 +1,107 @@ +package de.tum.cit.aet.artemis.programming.domain; + +import java.time.ZonedDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.core.domain.DomainObject; +import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.exercise.domain.participation.Participation; +import de.tum.cit.aet.artemis.programming.web.repository.RepositoryActionType; + +/** + * A Vcs access log entry. + */ +@Entity +@Table(name = "vcs_access_log") +@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class VcsAccessLog extends DomainObject { + + @ManyToOne + private User user; + + @ManyToOne + private Participation participation; + + @Column(name = "name") + private String name; + + @Column(name = "email") + private String email; + + @Column(name = "repository_action_type", nullable = false) + @Enumerated(EnumType.ORDINAL) + private RepositoryActionType repositoryActionType; + + @Column(name = "authentication_mechanism", nullable = false) + @Enumerated(EnumType.ORDINAL) + private AuthenticationMechanism authenticationMechanism; + + @Column(name = "commit_hash") + private String commitHash; + + @Column(name = "ip_address") + private String ipAddress; + + @Column(name = "timestamp") + private ZonedDateTime timestamp; + + public VcsAccessLog(User user, Participation participation, String name, String email, RepositoryActionType repositoryActionType, + AuthenticationMechanism authenticationMechanism, String commitHash, String ipAddress) { + this.user = user; + this.participation = participation; + this.name = name; + this.email = email; + this.repositoryActionType = repositoryActionType; + this.authenticationMechanism = authenticationMechanism; + this.commitHash = commitHash; + this.ipAddress = ipAddress; + this.timestamp = ZonedDateTime.now(); + } + + public VcsAccessLog() { + } + + public void setCommitHash(String commitHash) { + this.commitHash = commitHash; + } + + public User getUser() { + return user; + } + + public String getName() { + return name; + } + + public String getEmail() { + return email; + } + + public String getCommitHash() { + return commitHash; + } + + public ZonedDateTime getTimestamp() { + return timestamp; + } + + public AuthenticationMechanism getAuthenticationMechanism() { + return authenticationMechanism; + } + + public RepositoryActionType getRepositoryActionType() { + return repositoryActionType; + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/dto/VcsAccessLogDTO.java b/src/main/java/de/tum/cit/aet/artemis/programming/dto/VcsAccessLogDTO.java new file mode 100644 index 000000000000..b9896cf6efbe --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/dto/VcsAccessLogDTO.java @@ -0,0 +1,29 @@ +package de.tum.cit.aet.artemis.programming.dto; + +import java.time.ZonedDateTime; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.programming.domain.VcsAccessLog; + +/** + * DTO representing a VCS access log entry. + * + * @param id The id of the access log entry. + * @param userId The user's id associated with the access log event. + * @param name The name associated with the user. + * @param email The email associated with the user. + * @param repositoryActionType The type of action performed in the repository (read or write). + * @param authenticationMechanism The method the user used for authenticating to the repository. + * @param commitHash The latest commit hash at the access event. + * @param timestamp The date and time when the access event occurred. + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record VcsAccessLogDTO(Long id, Long userId, String name, String email, String repositoryActionType, String authenticationMechanism, String commitHash, + ZonedDateTime timestamp) { + + public static VcsAccessLogDTO of(VcsAccessLog vcsAccessLog) { + return new VcsAccessLogDTO(vcsAccessLog.getId(), vcsAccessLog.getUser().getId(), vcsAccessLog.getName(), vcsAccessLog.getEmail(), + vcsAccessLog.getRepositoryActionType().name(), vcsAccessLog.getAuthenticationMechanism().name(), vcsAccessLog.getCommitHash(), vcsAccessLog.getTimestamp()); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/VcsAccessLogRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/VcsAccessLogRepository.java new file mode 100644 index 000000000000..af342179e111 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/VcsAccessLogRepository.java @@ -0,0 +1,74 @@ +package de.tum.cit.aet.artemis.programming.repository; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_LOCALVC; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.context.annotation.Profile; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; +import de.tum.cit.aet.artemis.programming.domain.VcsAccessLog; + +/** + * Spring Data JPA repository for the User entity.
+ *
+ *

+ * Note: Please keep in mind that the User entities are soft-deleted when adding new queries to this repository. + * If you don't need deleted user entities, add `WHERE user.isDeleted = FALSE` to your query. + *

+ */ +@Profile(PROFILE_LOCALVC) +@Repository +public interface VcsAccessLogRepository extends ArtemisJpaRepository { + + /** + * Find the access log entry which does not have any commit hash yet + * + * @param participationId The id of the participation the repository belongs to + * @return a log entry belonging to the participationId, which has no commit hash + */ + @Query(""" + SELECT vcsAccessLog + FROM VcsAccessLog vcsAccessLog + WHERE vcsAccessLog.participation.id = :participationId + AND vcsAccessLog.commitHash IS NULL + ORDER BY vcsAccessLog.timestamp DESC + LIMIT 1 + """) + Optional findNewestByParticipationIdWhereCommitHashIsNull(@Param("participationId") long participationId); + + /** + * Retrieves a list of {@link VcsAccessLog} entities associated with the specified participation ID. + * The results are ordered by the log ID in ascending order. + * + * @param participationId the ID of the participation to filter the access logs by. + * @return a list of {@link VcsAccessLog} entities for the given participation ID, sorted by log ID in ascending order. + */ + @Query(""" + SELECT vcsAccessLog + FROM VcsAccessLog vcsAccessLog + WHERE vcsAccessLog.participation.id = :participationId + ORDER BY vcsAccessLog.id ASC + """) + List findAllByParticipationId(@Param("participationId") long participationId); + + /** + * Retrieves a list of {@link VcsAccessLog} entities associated with the specified participation ID. + * The results are ordered by the log ID in ascending order. + * + * @param date The date before which all log ids should be fetched + * + * @return a list of ids of the access logs, which have a timestamp before the date + */ + @Query(""" + SELECT vcsAccessLog.id + FROM VcsAccessLog vcsAccessLog + WHERE vcsAccessLog.timestamp < :date + """) + List findAllIdsBeforeDate(@Param("date") ZonedDateTime date); +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/RepositoryService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/RepositoryService.java index c181815e2143..ed5dd6cbae45 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/RepositoryService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/RepositoryService.java @@ -47,6 +47,7 @@ import de.tum.cit.aet.artemis.programming.domain.RepositoryType; import de.tum.cit.aet.artemis.programming.domain.VcsRepositoryUri; import de.tum.cit.aet.artemis.programming.dto.FileMove; +import de.tum.cit.aet.artemis.programming.service.localvc.VcsAccessLogService; /** * Service that provides utilities for managing files in a git repository. @@ -59,11 +60,14 @@ public class RepositoryService { private final ProfileService profileService; + private final Optional vcsAccessLogService; + private static final Logger log = LoggerFactory.getLogger(RepositoryService.class); - public RepositoryService(GitService gitService, ProfileService profileService) { + public RepositoryService(GitService gitService, ProfileService profileService, Optional vcsAccessLogService) { this.gitService = gitService; this.profileService = profileService; + this.vcsAccessLogService = vcsAccessLogService; } /** @@ -468,11 +472,15 @@ public void pullChanges(Repository repository) { * * @param repository for which to execute the commit. * @param user the user who has committed the changes in the online editor + * @param domainId the id of the domain Object (participation) owning the repository * @throws GitAPIException if the staging/committing process fails. */ - public void commitChanges(Repository repository, User user) throws GitAPIException { + public void commitChanges(Repository repository, User user, Long domainId) throws GitAPIException { gitService.stageAllChanges(repository); gitService.commitAndPush(repository, "Changes by Online Editor", true, user); + if (vcsAccessLogService.isPresent()) { + vcsAccessLogService.get().storeCodeEditorAccessLog(repository, user, domainId); + } } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/AutomaticVcsAccessLogCleanupService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/AutomaticVcsAccessLogCleanupService.java new file mode 100644 index 000000000000..f438a093edf6 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/AutomaticVcsAccessLogCleanupService.java @@ -0,0 +1,38 @@ +package de.tum.cit.aet.artemis.programming.service.localvc; + +import java.time.ZonedDateTime; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import de.tum.cit.aet.artemis.programming.repository.VcsAccessLogRepository; + +@Service +@Profile("scheduling & localvc") +public class AutomaticVcsAccessLogCleanupService { + + private static final Logger log = LoggerFactory.getLogger(AutomaticVcsAccessLogCleanupService.class); + + private final VcsAccessLogRepository vcsAccessLogRepository; + + @Value("${artemis.audit-events.retention-period:120}") + private int vcsAccessLogRetentionPeriod; + + public AutomaticVcsAccessLogCleanupService(VcsAccessLogRepository vcsAccessLogRepository) { + this.vcsAccessLogRepository = vcsAccessLogRepository; + } + + /** + * Deletes all vcs access log entries from the database which have a timestamp older than vcsAccessLogRetentionPeriod days (120 by default) + */ + @Scheduled(cron = "0 30 2 * * *") // execute this every night at 2:30:00 am + public void cleanup() { + var outDatedAccessLogs = vcsAccessLogRepository.findAllIdsBeforeDate(ZonedDateTime.now().minusDays(vcsAccessLogRetentionPeriod)); + log.info("Scheduled deletion of expired access log entries: deleting {} vcs access log entries", outDatedAccessLogs.size()); + vcsAccessLogRepository.deleteAllById(outDatedAccessLogs); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCServletService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCServletService.java index f331ead9ad44..90186a8c2c31 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCServletService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCServletService.java @@ -49,6 +49,7 @@ import de.tum.cit.aet.artemis.core.security.SecurityUtils; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.core.util.TimeLogUtil; +import de.tum.cit.aet.artemis.programming.domain.AuthenticationMechanism; import de.tum.cit.aet.artemis.programming.domain.Commit; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseParticipation; @@ -100,6 +101,9 @@ public class LocalVCServletService { private final ProgrammingTriggerService programmingTriggerService; + // TODO As soon as only LocalVC is supported, this Optional can be removed + private final Optional vcsAccessLogService; + private static URL localVCBaseUrl; private final ParticipationVCSAccessTokenRepository participationVCSAccessTokenRepository; @@ -132,7 +136,7 @@ public LocalVCServletService(AuthenticationManager authenticationManager, UserRe ProgrammingExerciseParticipationService programmingExerciseParticipationService, AuxiliaryRepositoryService auxiliaryRepositoryService, ContinuousIntegrationTriggerService ciTriggerService, ProgrammingSubmissionService programmingSubmissionService, ProgrammingMessagingService programmingMessagingService, ProgrammingTriggerService programmingTriggerService, - ParticipationVCSAccessTokenRepository participationVCSAccessTokenRepository) { + ParticipationVCSAccessTokenRepository participationVCSAccessTokenRepository, Optional vcsAccessLogService) { this.authenticationManager = authenticationManager; this.userRepository = userRepository; this.programmingExerciseRepository = programmingExerciseRepository; @@ -145,6 +149,7 @@ public LocalVCServletService(AuthenticationManager authenticationManager, UserRe this.programmingMessagingService = programmingMessagingService; this.programmingTriggerService = programmingTriggerService; this.participationVCSAccessTokenRepository = participationVCSAccessTokenRepository; + this.vcsAccessLogService = vcsAccessLogService; } /** @@ -238,13 +243,36 @@ public void authenticateAndAuthorizeGitRequest(HttpServletRequest request, Repos throw new LocalVCForbiddenException(); } - authorizeUser(repositoryTypeOrUserName, user, exercise, repositoryAction, localVCRepositoryUri.isPracticeRepository()); + var authenticationMechanism = resolveAuthenticationMechanism(authorizationHeader, user); + var ipAddress = request.getRemoteAddr(); + authorizeUser(repositoryTypeOrUserName, user, exercise, repositoryAction, authenticationMechanism, ipAddress, localVCRepositoryUri); request.setAttribute("user", user); log.debug("Authorizing user {} for repository {} took {}", user.getLogin(), localVCRepositoryUri, TimeLogUtil.formatDurationFrom(timeNanoStart)); } + /** + * Resolves the user's authentication mechanism for the repository + * + * @param authorizationHeader the request's authorizationHeader, containing the token or password + * @param user the user + * @return the authentication type + * @throws LocalVCAuthException if extracting the token or password from the authorizationHeader fails + */ + private AuthenticationMechanism resolveAuthenticationMechanism(String authorizationHeader, User user) throws LocalVCAuthException { + UsernameAndPassword usernameAndPassword = extractUsernameAndPassword(authorizationHeader); + + String password = usernameAndPassword.password(); + if (!password.startsWith(TOKEN_PREFIX)) { + return AuthenticationMechanism.PASSWORD; + } + if (password.equals(user.getVcsAccessToken())) { + return AuthenticationMechanism.USER_VCS_ACCESS_TOKEN; + } + return AuthenticationMechanism.PARTICIPATION_VCS_ACCESS_TOKEN; + } + private User authenticateUser(String authorizationHeader, ProgrammingExercise exercise, LocalVCRepositoryUri localVCRepositoryUri) throws LocalVCAuthException { UsernameAndPassword usernameAndPassword = extractUsernameAndPassword(authorizationHeader); @@ -377,11 +405,14 @@ private UsernameAndPassword extractUsernameAndPassword(String authorizationHeade * @param user The user that wants to access the repository. * @param exercise The exercise the repository belongs to. * @param repositoryActionType The type of the action the user wants to perform. - * @param isPracticeRepository Whether the repository is a practice repository. + * @param authenticationMechanism The authentication mechanism used by the user to authenticate to the repository + * @param ipAddress The ip address of the user + * @param localVCRepositoryUri The URI of the local repository. + * * @throws LocalVCForbiddenException If the user is not allowed to access the repository. */ - public void authorizeUser(String repositoryTypeOrUserName, User user, ProgrammingExercise exercise, RepositoryActionType repositoryActionType, boolean isPracticeRepository) - throws LocalVCForbiddenException { + public void authorizeUser(String repositoryTypeOrUserName, User user, ProgrammingExercise exercise, RepositoryActionType repositoryActionType, + AuthenticationMechanism authenticationMechanism, String ipAddress, LocalVCRepositoryUri localVCRepositoryUri) throws LocalVCForbiddenException { if (repositoryTypeOrUserName.equals(RepositoryType.TESTS.toString()) || auxiliaryRepositoryService.isAuxiliaryRepositoryOfExercise(repositoryTypeOrUserName, exercise)) { // Test and auxiliary repositories are only accessible by instructors and higher. @@ -396,7 +427,8 @@ public void authorizeUser(String repositoryTypeOrUserName, User user, Programmin ProgrammingExerciseParticipation participation; try { - participation = programmingExerciseParticipationService.getParticipationForRepository(exercise, repositoryTypeOrUserName, isPracticeRepository, false); + participation = programmingExerciseParticipationService.getParticipationForRepository(exercise, repositoryTypeOrUserName, localVCRepositoryUri.isPracticeRepository(), + false); } catch (EntityNotFoundException e) { throw new LocalVCInternalException( @@ -409,6 +441,18 @@ public void authorizeUser(String repositoryTypeOrUserName, User user, Programmin catch (AccessForbiddenException e) { throw new LocalVCForbiddenException(e); } + String commitHash = null; + try { + if (repositoryActionType == RepositoryActionType.READ) { + commitHash = getLatestCommitHash(repositories.get(localVCRepositoryUri.getRelativeRepositoryPath().toString())); + } + } + catch (GitAPIException e) { + log.warn("Failed to obtain commit hash for repository {}. Error: {}", localVCRepositoryUri.getRelativeRepositoryPath().toString(), e.getMessage()); + } + // Write a access log entry to the database + String finalCommitHash = commitHash; + vcsAccessLogService.ifPresent(service -> service.storeAccessLog(user, participation, repositoryActionType, authenticationMechanism, finalCommitHash, ipAddress)); } /** @@ -475,6 +519,10 @@ public void processNewPush(String commitHash, Repository repository) { // Process push to any repository other than the test repository. processNewPushToRepository(participation, commit); + + // For push the correct commitHash is only available here, therefore the preliminary null value is overwritten + String finalCommitHash = commitHash; + vcsAccessLogService.ifPresent(service -> service.updateCommitHash(participation, finalCommitHash)); } catch (GitAPIException | IOException e) { // This catch clause does not catch exceptions that happen during runBuildJob() as that method is called asynchronously. diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/SshGitLocationResolverService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/SshGitLocationResolverService.java index 99b90e06425d..a61712685ef7 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/SshGitLocationResolverService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/SshGitLocationResolverService.java @@ -21,6 +21,7 @@ import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; import de.tum.cit.aet.artemis.core.exception.localvc.LocalVCForbiddenException; import de.tum.cit.aet.artemis.core.exception.localvc.LocalVCInternalException; +import de.tum.cit.aet.artemis.programming.domain.AuthenticationMechanism; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository; import de.tum.cit.aet.artemis.programming.service.localvc.ssh.SshConstants; @@ -76,7 +77,8 @@ public Path resolveRootDirectory(String command, String[] args, ServerSession se else { final var user = session.getAttribute(SshConstants.USER_KEY); try { - localVCServletService.authorizeUser(repositoryTypeOrUserName, user, exercise, repositoryAction, localVCRepositoryUri.isPracticeRepository()); + localVCServletService.authorizeUser(repositoryTypeOrUserName, user, exercise, repositoryAction, AuthenticationMechanism.SSH, session.getClientAddress().toString(), + localVCRepositoryUri); } catch (LocalVCForbiddenException e) { log.error("User {} does not have access to the repository {}", user.getLogin(), repositoryPath); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/VcsAccessLogService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/VcsAccessLogService.java new file mode 100644 index 000000000000..d77b37c02a7f --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/VcsAccessLogService.java @@ -0,0 +1,86 @@ +package de.tum.cit.aet.artemis.programming.service.localvc; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_LOCALVC; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.exercise.domain.participation.Participation; +import de.tum.cit.aet.artemis.exercise.repository.ParticipationRepository; +import de.tum.cit.aet.artemis.programming.domain.AuthenticationMechanism; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseParticipation; +import de.tum.cit.aet.artemis.programming.domain.Repository; +import de.tum.cit.aet.artemis.programming.domain.VcsAccessLog; +import de.tum.cit.aet.artemis.programming.repository.VcsAccessLogRepository; +import de.tum.cit.aet.artemis.programming.web.repository.RepositoryActionType; + +@Profile(PROFILE_LOCALVC) +@Service +public class VcsAccessLogService { + + private static final Logger log = LoggerFactory.getLogger(VcsAccessLogService.class); + + private final VcsAccessLogRepository vcsAccessLogRepository; + + private final ParticipationRepository participationRepository; + + VcsAccessLogService(VcsAccessLogRepository vcsAccessLogRepository, ParticipationRepository participationRepository) { + this.vcsAccessLogRepository = vcsAccessLogRepository; + this.participationRepository = participationRepository; + } + + /** + * Creates a vcs access log entry and stores it to the database + * + * @param user The user accessing the repository + * @param participation The participation which owns the repository + * @param actionType The action type: READ or WRITE + * @param authenticationMechanism The used authentication mechanism: password, vcs token (user/participation), SSH or code editor + * @param commitHash The latest commit hash + * @param ipAddress The ip address of the user accessing the repository + */ + public void storeAccessLog(User user, ProgrammingExerciseParticipation participation, RepositoryActionType actionType, AuthenticationMechanism authenticationMechanism, + String commitHash, String ipAddress) { + log.debug("Storing access operation for user {}", user); + + VcsAccessLog accessLogEntry = new VcsAccessLog(user, (Participation) participation, user.getName(), user.getEmail(), actionType, authenticationMechanism, commitHash, + ipAddress); + vcsAccessLogRepository.save(accessLogEntry); + } + + /** + * Updates the commit hash after a successful push + * + * @param participation The participation to which the repository belongs to + * @param commitHash The newest commit hash which should get set for the access log entry + */ + public void updateCommitHash(ProgrammingExerciseParticipation participation, String commitHash) { + vcsAccessLogRepository.findNewestByParticipationIdWhereCommitHashIsNull(participation.getId()).ifPresent(entry -> { + entry.setCommitHash(commitHash); + vcsAccessLogRepository.save(entry); + }); + } + + /** + * Stores the log for a push from the code editor. + * + * @param repo The repository to which the push is executed + * @param user The user submitting the change + * @param participationId The id of the participation belonging to the repository + * @throws GitAPIException if an error occurs while retrieving the git log + */ + public void storeCodeEditorAccessLog(Repository repo, User user, Long participationId) throws GitAPIException { + try (Git git = new Git(repo)) { + String lastCommitHash = git.log().setMaxCount(1).call().iterator().next().getName(); + var participation = participationRepository.findById(participationId); + if (participation.isPresent() && participation.get() instanceof ProgrammingExerciseParticipation programmingParticipation) { + storeAccessLog(user, programmingParticipation, RepositoryActionType.WRITE, AuthenticationMechanism.CODE_EDITOR, lastCommitHash, 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 a1e104e6fde4..d06fdf5fb975 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 @@ -11,6 +11,8 @@ import java.util.stream.Collectors; import org.eclipse.jgit.api.errors.GitAPIException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Profile; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -31,6 +33,7 @@ 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; +import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInExercise.EnforceAtLeastInstructorInExercise; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.exam.repository.StudentExamRepository; import de.tum.cit.aet.artemis.exam.service.ExamService; @@ -42,10 +45,13 @@ import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseStudentParticipation; import de.tum.cit.aet.artemis.programming.domain.ProgrammingSubmission; import de.tum.cit.aet.artemis.programming.domain.RepositoryType; +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.VcsAccessLogDTO; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseStudentParticipationRepository; +import de.tum.cit.aet.artemis.programming.repository.VcsAccessLogRepository; import de.tum.cit.aet.artemis.programming.service.ProgrammingExerciseParticipationService; import de.tum.cit.aet.artemis.programming.service.ProgrammingSubmissionService; import de.tum.cit.aet.artemis.programming.service.RepositoryService; @@ -55,6 +61,8 @@ @RequestMapping("api/") public class ProgrammingExerciseParticipationResource { + private static final Logger log = LoggerFactory.getLogger(ProgrammingExerciseParticipationResource.class); + private static final String ENTITY_NAME = "programmingExerciseParticipation"; private final ParticipationRepository participationRepository; @@ -79,11 +87,13 @@ public class ProgrammingExerciseParticipationResource { private final StudentExamRepository studentExamRepository; + private final Optional vcsAccessLogRepository; + public ProgrammingExerciseParticipationResource(ProgrammingExerciseParticipationService programmingExerciseParticipationService, ResultRepository resultRepository, ParticipationRepository participationRepository, ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository, ProgrammingSubmissionService submissionService, ProgrammingExerciseRepository programmingExerciseRepository, AuthorizationCheckService authCheckService, ResultService resultService, ParticipationAuthorizationCheckService participationAuthCheckService, RepositoryService repositoryService, - StudentExamRepository studentExamRepository) { + StudentExamRepository studentExamRepository, Optional vcsAccessLogRepository) { this.programmingExerciseParticipationService = programmingExerciseParticipationService; this.participationRepository = participationRepository; this.programmingExerciseStudentParticipationRepository = programmingExerciseStudentParticipationRepository; @@ -95,6 +105,7 @@ public ProgrammingExerciseParticipationResource(ProgrammingExerciseParticipation this.participationAuthCheckService = participationAuthCheckService; this.repositoryService = repositoryService; this.studentExamRepository = studentExamRepository; + this.vcsAccessLogRepository = vcsAccessLogRepository; } /** @@ -305,6 +316,27 @@ public ResponseEntity> getCommitHistoryForParticipationRepo( return ResponseEntity.ok(commitInfo); } + /** + * GET /programming-exercise-participations/{participationId}/vcs-access-log : + * Here we check if the user is least an instructor for the exercise. If true the user can have access to the vcs access log of any participation of the exercise. + * + * @param participationId the id of the participation for which to retrieve the vcs access log + * @return the ResponseEntity with status 200 (OK) and with body containing a list of vcsAccessLogDTOs of the participation, or 400 (Bad request) if localVC is not enabled. + */ + @GetMapping("programming-exercise-participations/{participationId}/vcs-access-log") + @EnforceAtLeastInstructor + public ResponseEntity> getVcsAccessLogForParticipationRepo(@PathVariable long participationId) { + if (vcsAccessLogRepository.isEmpty()) { + return ResponseEntity.badRequest().build(); + } + ProgrammingExerciseStudentParticipation participation = programmingExerciseStudentParticipationRepository.findByIdElseThrow(participationId); + participationAuthCheckService.checkCanAccessParticipationElseThrow(participation); + log.info("Fetching VCS access logs for participation ID: {}", participationId); + List vcsAccessLogs = vcsAccessLogRepository.get().findAllByParticipationId(participationId); + var vcsAccessLogDTOs = vcsAccessLogs.stream().map(VcsAccessLogDTO::of).toList(); + return ResponseEntity.ok(vcsAccessLogDTOs); + } + /** * GET /programming-exercise/{exerciseID}/commit-history/{repositoryType} : Get the commit history of a programming exercise repository. The repository type can be TEMPLATE or * SOLUTION or TESTS. @@ -392,6 +424,34 @@ else if (repositoryType != null) { } } + /** + * Retrieves the VCS access logs for the specified programming exercise's template or solution participation + * + * @param exerciseId the ID of the programming exercise + * @param repositoryType the type of repository (either TEMPLATE or SOLUTION) for which to retrieve the logs. + * @return the ResponseEntity with status 200 (OK) and with body containing a list of vcsAccessLogDTOs of the participation, or 400 (Bad request) if localVC is not enabled. + * @throws BadRequestAlertException if the repository type is invalid + */ + @GetMapping("programming-exercise/{exerciseId}/vcs-access-log/{repositoryType}") + @EnforceAtLeastInstructorInExercise + public ResponseEntity> getVcsAccessLogForExerciseRepository(@PathVariable long exerciseId, @PathVariable RepositoryType repositoryType) { + if (vcsAccessLogRepository.isEmpty()) { + return ResponseEntity.badRequest().build(); + } + if (repositoryType != RepositoryType.TEMPLATE && repositoryType != RepositoryType.SOLUTION) { + throw new BadRequestAlertException("Can only get vcs access log from template and assignment repositories", ENTITY_NAME, "incorrect repositoryType"); + } + ProgrammingExercise programmingExercise = programmingExerciseRepository.findByIdWithTemplateAndSolutionParticipationAndAuxiliaryRepositoriesElseThrow(exerciseId); + authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.INSTRUCTOR, programmingExercise, null); + log.info("Fetching VCS access logs for exercise ID: {} and repository type: {}", exerciseId, repositoryType); + + var participation = repositoryType == RepositoryType.TEMPLATE ? programmingExercise.getTemplateParticipation() : programmingExercise.getSolutionParticipation(); + + List vcsAccessLogs = vcsAccessLogRepository.get().findAllByParticipationId(participation.getId()); + var vcsAccessLogDTOs = vcsAccessLogs.stream().map(VcsAccessLogDTO::of).toList(); + return ResponseEntity.ok(vcsAccessLogDTOs); + } + /** * Checks if the user has access to the participation. * If the exercise has not started yet and the user is a student, access is denied. @@ -426,5 +486,4 @@ private boolean shouldHideExamExerciseResults(ProgrammingExerciseStudentParticip } return false; } - } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/repository/RepositoryResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/repository/RepositoryResource.java index bd50617ee04d..f9ca7350afd1 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/repository/RepositoryResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/repository/RepositoryResource.java @@ -281,7 +281,7 @@ public ResponseEntity commitChanges(Long domainId) { return executeAndCheckForExceptions(() -> { Repository repository = getRepository(domainId, RepositoryActionType.WRITE, true); - repositoryService.commitChanges(repository, user); + repositoryService.commitChanges(repository, user, domainId); // Trigger a build, and process the result. Only implemented for local CI. // For GitLab + Jenkins, webhooks were added when creating the repository, // that notify the CI system when the commit happens and thus trigger the build. diff --git a/src/main/resources/config/application-prod.yml b/src/main/resources/config/application-prod.yml index 7d86700066e4..576e5ca01cde 100644 --- a/src/main/resources/config/application-prod.yml +++ b/src/main/resources/config/application-prod.yml @@ -109,8 +109,7 @@ jhipster: port: 5000 ring-buffer-size: 512 audit-events: - retention-period: 120 # Number of days before audit events are deleted. - + retention-period: 120 # Number of days before audit events and VCS access logs are deleted. # Properties to be exposed on the /info management endpoint info: guided-tour: diff --git a/src/main/resources/config/liquibase/changelog/20240804144500_changelog.xml b/src/main/resources/config/liquibase/changelog/20240804144500_changelog.xml new file mode 100644 index 000000000000..72b4d0498221 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20240804144500_changelog.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index f8be6b6255a0..2c204094c0ff 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -22,6 +22,8 @@ + + diff --git a/src/main/webapp/app/entities/vcs-access-log-entry.model.ts b/src/main/webapp/app/entities/vcs-access-log-entry.model.ts new file mode 100644 index 000000000000..c2571e9ddd0d --- /dev/null +++ b/src/main/webapp/app/entities/vcs-access-log-entry.model.ts @@ -0,0 +1,13 @@ +import { BaseEntity } from 'app/shared/model/base-entity'; +import dayjs from 'dayjs/esm'; + +export class VcsAccessLogDTO implements BaseEntity { + public id?: number; + public userId?: number; + public name?: string; + public email?: string; + public repositoryActionType: string; + public authenticationMechanism: string; + public commitHash?: string; + public timestamp: dayjs.Dayjs; +} diff --git a/src/main/webapp/app/exercises/programming/manage/programming-exercise-management-routing.module.ts b/src/main/webapp/app/exercises/programming/manage/programming-exercise-management-routing.module.ts index 54c277c9bd7d..cf949670dcf9 100644 --- a/src/main/webapp/app/exercises/programming/manage/programming-exercise-management-routing.module.ts +++ b/src/main/webapp/app/exercises/programming/manage/programming-exercise-management-routing.module.ts @@ -18,6 +18,7 @@ import { RepositoryViewComponent } from 'app/localvc/repository-view/repository- import { CommitHistoryComponent } from 'app/localvc/commit-history/commit-history.component'; import { CommitDetailsViewComponent } from 'app/localvc/commit-details-view/commit-details-view.component'; import { LocalVCGuard } from 'app/localvc/localvc-guard.service'; +import { VcsRepositoryAccessLogViewComponent } from 'app/localvc/vcs-repository-access-log-view/vcs-repository-access-log-view.component'; @Injectable({ providedIn: 'root' }) export class ProgrammingExerciseResolve implements Resolve { @@ -183,6 +184,18 @@ export const routes: Routes = [ }, canActivate: [LocalVCGuard], }, + { + path: ':courseId/programming-exercises/:exerciseId/repository/:repositoryType/vcs-access-log', + component: VcsRepositoryAccessLogViewComponent, + data: { + authorities: [Authority.ADMIN, Authority.INSTRUCTOR], + pageTitle: 'artemisApp.repository.title', + flushRepositoryCacheAfter: 900000, // 15 min + participationCache: {}, + repositoryCache: {}, + }, + canActivate: [LocalVCGuard], + }, { path: ':courseId/programming-exercises/:exerciseId/repository/:repositoryType/commit-history/:commitHash', component: CommitDetailsViewComponent, @@ -219,6 +232,18 @@ export const routes: Routes = [ }, canActivate: [UserRouteAccessService, LocalVCGuard], }, + { + path: ':courseId/programming-exercises/:exerciseId/participations/:participationId/repository/vcs-access-log', + component: VcsRepositoryAccessLogViewComponent, + data: { + authorities: [Authority.ADMIN, Authority.INSTRUCTOR], + pageTitle: 'artemisApp.repository.title', + flushRepositoryCacheAfter: 900000, // 15 min + participationCache: {}, + repositoryCache: {}, + }, + canActivate: [UserRouteAccessService, LocalVCGuard], + }, { path: ':courseId/programming-exercises/:exerciseId/participations/:participationId/repository/commit-history/:commitHash', component: CommitDetailsViewComponent, diff --git a/src/main/webapp/app/exercises/programming/manage/services/programming-exercise-participation.service.ts b/src/main/webapp/app/exercises/programming/manage/services/programming-exercise-participation.service.ts index e5ece788a0fb..e83414219bb6 100644 --- a/src/main/webapp/app/exercises/programming/manage/services/programming-exercise-participation.service.ts +++ b/src/main/webapp/app/exercises/programming/manage/services/programming-exercise-participation.service.ts @@ -8,6 +8,7 @@ import { Result } from 'app/entities/result.model'; import { EntityTitleService, EntityType } from 'app/shared/layouts/navbar/entity-title.service'; import { createRequestOption } from 'app/shared/util/request.util'; import { Observable, map, tap } from 'rxjs'; +import { VcsAccessLogDTO } from 'app/entities/vcs-access-log-entry.model'; export interface IProgrammingExerciseParticipationService { getLatestResultWithFeedback: (participationId: number, withSubmission: boolean) => Observable; @@ -145,6 +146,33 @@ export class ProgrammingExerciseParticipationService implements IProgrammingExer return this.http.get(`${this.resourceUrlParticipations}${participationId}/commits-info`); } + /** + * Get the vcs access log for a given participation id. + * The current user needs to be at least an instructor in the course of the participation. + * @param participationId of the participation to get the vcs Access log + */ + getVcsAccessLogForParticipation(participationId: number): Observable { + return this.http + .get(`${this.resourceUrlParticipations}${participationId}/vcs-access-log`, { observe: 'response' }) + .pipe(map((res: HttpResponse) => res.body ?? undefined)); + } + + /** + * Get the vcs access log for a given exercise id and the repository type. + * The current user needs to be at least a instructor in the course of the participation. + * @param exerciseId of the exercise to get the vcs Access log + * @param repositoryType of the repository of the exercise, to get the vcs Access log + */ + getVcsAccessLogForRepository(exerciseId: number, repositoryType: string): Observable { + const params: { [key: string]: number | string } = {}; + if (repositoryType) { + params['repositoryType'] = repositoryType; + } + return this.http + .get(`${this.resourceUrl}${exerciseId}/vcs-access-log/${repositoryType}`, { observe: 'response' }) + .pipe(map((res: HttpResponse) => res.body ?? undefined)); + } + /** * Get the repository files with content for a given participation id at a specific commit hash. * The current user needs to be at least a student in the course of the participation. diff --git a/src/main/webapp/app/localvc/repository-view/repository-view.component.html b/src/main/webapp/app/localvc/repository-view/repository-view.component.html index b3e821dbf431..6a41335a4f51 100644 --- a/src/main/webapp/app/localvc/repository-view/repository-view.component.html +++ b/src/main/webapp/app/localvc/repository-view/repository-view.component.html @@ -34,6 +34,13 @@

} + @if (vcsAccessLogRoute && enableVcsAccessLog && allowVcsAccessLog && localVcEnabled) { + + + + + } + @if (exercise?.allowOfflineIde) { { + this.localVcEnabled = profileInfo.activeProfiles.includes(PROFILE_LOCALVC); + }); } /** @@ -122,6 +133,7 @@ export class RepositoryViewComponent implements OnInit, OnDestroy { this.participationCouldNotBeFetched = true; this.loadingParticipation = false; } + this.allowVcsAccessLog = this.accountService.isAtLeastInstructorInCourse(this.getCourseFromExercise(this.exercise)); }), ) .subscribe({ @@ -146,6 +158,7 @@ export class RepositoryViewComponent implements OnInit, OnDestroy { this.domainService.setDomain([DomainType.PARTICIPATION, participationWithResults]); this.participation = participationWithResults; this.exercise = this.participation.exercise as ProgrammingExercise; + this.allowVcsAccessLog = this.accountService.isAtLeastInstructorInCourse(this.getCourseFromExercise(this.exercise)); this.repositoryUri = this.participation.repositoryUri!; }), ) diff --git a/src/main/webapp/app/localvc/vcs-repository-access-log-view/vcs-repository-access-log-view.component.html b/src/main/webapp/app/localvc/vcs-repository-access-log-view/vcs-repository-access-log-view.component.html new file mode 100644 index 000000000000..32f6eaaf0da6 --- /dev/null +++ b/src/main/webapp/app/localvc/vcs-repository-access-log-view/vcs-repository-access-log-view.component.html @@ -0,0 +1,34 @@ + +
+

+
+ +
+ + + + + + + + + + + + + + @for (entry of vcsAccessLogEntries(); track entry; let i = $index) { + + + + + + + + + + } + +
#
{{ i }}{{ entry.userId }}{{ entry.name + ', ' + entry.email }}{{ entry.repositoryActionType }}{{ entry.authenticationMechanism }}{{ entry.commitHash }}{{ entry.timestamp }}
+
+
diff --git a/src/main/webapp/app/localvc/vcs-repository-access-log-view/vcs-repository-access-log-view.component.ts b/src/main/webapp/app/localvc/vcs-repository-access-log-view/vcs-repository-access-log-view.component.ts new file mode 100644 index 000000000000..0f72191f8405 --- /dev/null +++ b/src/main/webapp/app/localvc/vcs-repository-access-log-view/vcs-repository-access-log-view.component.ts @@ -0,0 +1,65 @@ +import { Component, computed, effect, inject, signal } from '@angular/core'; +import { Observable, lastValueFrom } from 'rxjs'; +import { ActivatedRoute } from '@angular/router'; +import { ProgrammingExerciseParticipationService } from 'app/exercises/programming/manage/services/programming-exercise-participation.service'; +import { VcsAccessLogDTO } from 'app/entities/vcs-access-log-entry.model'; +import { AlertService } from 'app/core/util/alert.service'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; + +@Component({ + selector: 'jhi-vcs-repository-access-log-view', + templateUrl: './vcs-repository-access-log-view.component.html', + standalone: true, + imports: [TranslateDirective], +}) +export class VcsRepositoryAccessLogViewComponent { + private readonly route = inject(ActivatedRoute); + private readonly programmingExerciseParticipationService = inject(ProgrammingExerciseParticipationService); + private readonly alertService = inject(AlertService); + + protected readonly vcsAccessLogEntries = signal([]); + + private readonly params = toSignal(this.route.params, { requireSync: true }); + private readonly participationId = computed(() => { + const participationId = this.params().participationId; + if (participationId) { + return Number(participationId); + } + return undefined; + }); + private readonly exerciseId = computed(() => Number(this.params().exerciseId)); + private readonly repositoryType = computed(() => String(this.params().repositoryType)); + + constructor() { + effect( + async () => { + if (this.participationId()) { + await this.loadVcsAccessLogForParticipation(this.participationId()!); + } else { + await this.loadVcsAccessLog(this.exerciseId(), this.repositoryType()); + } + }, + { allowSignalWrites: true }, + ); + } + + public async loadVcsAccessLogForParticipation(participationId: number) { + await this.extractEntries(() => this.programmingExerciseParticipationService.getVcsAccessLogForParticipation(participationId)); + } + + public async loadVcsAccessLog(exerciseId: number, repositoryType: string) { + await this.extractEntries(() => this.programmingExerciseParticipationService.getVcsAccessLogForRepository(exerciseId, repositoryType)); + } + + private async extractEntries(fetchVcsAccessLogs: () => Observable) { + try { + const accessLogEntries = await lastValueFrom(fetchVcsAccessLogs()); + if (accessLogEntries) { + this.vcsAccessLogEntries.set(accessLogEntries); + } + } catch (error) { + this.alertService.error('artemisApp.repository.vcsAccessLog.error'); + } + } +} diff --git a/src/main/webapp/i18n/de/repository.json b/src/main/webapp/i18n/de/repository.json index 241f5d8bc21d..6bd6d6d28842 100644 --- a/src/main/webapp/i18n/de/repository.json +++ b/src/main/webapp/i18n/de/repository.json @@ -20,6 +20,17 @@ "empty": "Keine Änderungen", "fileUnchanged": "Keine Änderungen am Dateiinhalt" } + }, + "vcsAccessLog": { + "title": "VCS-Zugriffsprotokoll", + "openVcsAccessLog": "Zugriffsprotokoll öffnen", + "userId": "Benutzer Id", + "author": "Autor", + "actionType": "Aktionstyp", + "authMechanism": "Zugriffsart", + "commitHash": "Commit Hash", + "timeStamp": "Zeitstempel", + "error": "VCS-Zugriffsprotokoll konnte nicht abgerufen werden" } } } diff --git a/src/main/webapp/i18n/en/repository.json b/src/main/webapp/i18n/en/repository.json index 59b50fcb0481..8ed72d96e6ee 100644 --- a/src/main/webapp/i18n/en/repository.json +++ b/src/main/webapp/i18n/en/repository.json @@ -20,6 +20,17 @@ "empty": "No changes", "fileUnchanged": "No changes in the file content" } + }, + "vcsAccessLog": { + "title": "VCS Access Log", + "openVcsAccessLog": "Open Access Log", + "userId": "User Id", + "author": "Author", + "actionType": "Action Type", + "authMechanism": "Access Type", + "commitHash": "Commit Hash", + "timeStamp": "Timestamp", + "error": "VCS Access log could not be retrieved" } } } diff --git a/src/test/java/de/tum/cit/aet/artemis/localvcci/LocalVCLocalCIParticipationIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/localvcci/LocalVCLocalCIParticipationIntegrationTest.java index 4ccddacc3fbf..1ac8510ab262 100644 --- a/src/test/java/de/tum/cit/aet/artemis/localvcci/LocalVCLocalCIParticipationIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/localvcci/LocalVCLocalCIParticipationIntegrationTest.java @@ -4,6 +4,7 @@ import java.time.ZonedDateTime; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; @@ -14,9 +15,15 @@ import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.exercise.domain.participation.StudentParticipation; import de.tum.cit.aet.artemis.exercise.programming.ProgrammingExerciseUtilService; +import de.tum.cit.aet.artemis.participation.ParticipationUtilService; +import de.tum.cit.aet.artemis.programming.domain.AuthenticationMechanism; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; import de.tum.cit.aet.artemis.programming.domain.TemplateProgrammingExerciseParticipation; +import de.tum.cit.aet.artemis.programming.domain.VcsAccessLog; +import de.tum.cit.aet.artemis.programming.dto.VcsAccessLogDTO; +import de.tum.cit.aet.artemis.programming.repository.VcsAccessLogRepository; import de.tum.cit.aet.artemis.programming.service.localvc.LocalVCRepositoryUri; +import de.tum.cit.aet.artemis.programming.web.repository.RepositoryActionType; import de.tum.cit.aet.artemis.util.LocalRepository; class LocalVCLocalCIParticipationIntegrationTest extends AbstractSpringIntegrationLocalCILocalVCTest { @@ -26,6 +33,21 @@ class LocalVCLocalCIParticipationIntegrationTest extends AbstractSpringIntegrati @Autowired private ProgrammingExerciseUtilService programmingExerciseUtilService; + @Autowired + private VcsAccessLogRepository vcsAccessLogRepository; + + @Autowired + private ParticipationUtilService participationUtilService; + + private ProgrammingExercise programmingExercise; + + @BeforeEach + void initTestCase() { + userUtilService.addUsers(TEST_PREFIX, 4, 2, 0, 2); + Course course = programmingExerciseUtilService.addCourseWithOneProgrammingExerciseAndTestCases(); + programmingExercise = exerciseUtilService.getFirstExerciseWithType(course, ProgrammingExercise.class); + } + @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void testStartParticipation() throws Exception { @@ -63,4 +85,27 @@ void testStartParticipation() throws Exception { templateRepository.resetLocalRepo(); } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGetVcsAccessLog() throws Exception { + var participation = participationUtilService.addStudentParticipationForProgrammingExercise(programmingExercise, TEST_PREFIX + "instructor1"); + var user = userRepository.getUser(); + vcsAccessLogRepository.save(new VcsAccessLog(user, participation, "instructor", "instructorMail@mail.de", RepositoryActionType.READ, AuthenticationMechanism.SSH, "", "")); + var li = request.getList("/api/programming-exercise-participations/" + participation.getId() + "/vcs-access-log", HttpStatus.OK, VcsAccessLogDTO.class); + assertThat(li.size()).isEqualTo(1); + assertThat(li.getFirst().userId()).isEqualTo(user.getId()); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGetVcsAccessLogOfTemplateParticipation() throws Exception { + var user = userRepository.getUser(); + vcsAccessLogRepository.save(new VcsAccessLog(user, programmingExercise.getTemplateParticipation(), "instructor", "instructorMail@mail.de", RepositoryActionType.READ, + AuthenticationMechanism.SSH, "", "")); + var li = request.getList("/api/programming-exercise/" + programmingExercise.getId() + "/vcs-access-log/TEMPLATE", HttpStatus.OK, VcsAccessLogDTO.class); + assertThat(li.size()).isEqualTo(1); + assertThat(li.getFirst().userId()).isEqualTo(user.getId()); + } + } diff --git a/src/test/javascript/spec/component/localvc/repository-view.component.spec.ts b/src/test/javascript/spec/component/localvc/repository-view.component.spec.ts index 5f4fae4014a9..ff004d03c91b 100644 --- a/src/test/javascript/spec/component/localvc/repository-view.component.spec.ts +++ b/src/test/javascript/spec/component/localvc/repository-view.component.spec.ts @@ -16,6 +16,8 @@ import { HttpResponse } from '@angular/common/http'; import { ProgrammingExercise } from 'app/entities/programming/programming-exercise.model'; import { DueDateStat } from 'app/course/dashboards/due-date-stat.model'; import { ProgrammingExerciseStudentParticipation } from 'app/entities/participation/programming-exercise-student-participation.model'; +import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { MockProfileService } from '../../helpers/mocks/service/mock-profile.service'; describe('RepositoryViewComponent', () => { let component: RepositoryViewComponent; @@ -39,6 +41,7 @@ describe('RepositoryViewComponent', () => { { provide: ProgrammingExerciseParticipationService, useClass: MockProgrammingExerciseParticipationService }, { provide: ProgrammingExerciseService, useClass: MockProgrammingExerciseService }, { provide: Router, useClass: MockRouter }, + { provide: ProfileService, useClass: MockProfileService }, ], }) .compileComponents() @@ -206,7 +209,16 @@ describe('RepositoryViewComponent', () => { const mockParticipation: ProgrammingExerciseStudentParticipation = { id: 2, repositoryUri: 'student-repo-uri', - exercise: { id: 1, numberOfAssessmentsOfCorrectionRounds: [new DueDateStat()], studentAssignedTeamIdComputed: true, secondCorrectionEnabled: true }, + exercise: { + id: 1, + numberOfAssessmentsOfCorrectionRounds: [new DueDateStat()], + studentAssignedTeamIdComputed: true, + secondCorrectionEnabled: true, + course: { + instructorGroupName: 'instructorGroup', + isAtLeastInstructor: true, + }, + }, results: [ { id: 3, successful: true, score: 100, rated: true, hasComplaint: false, exampleResult: false, testCaseCount: 10, passedTestCaseCount: 10, codeIssueCount: 0 }, { id: 4, successful: true, score: 100, rated: true, hasComplaint: false, exampleResult: false, testCaseCount: 10, passedTestCaseCount: 10, codeIssueCount: 0 }, diff --git a/src/test/javascript/spec/component/localvc/vcs-repository-access-log-view.component.spec.ts b/src/test/javascript/spec/component/localvc/vcs-repository-access-log-view.component.spec.ts new file mode 100644 index 000000000000..44d03a38d661 --- /dev/null +++ b/src/test/javascript/spec/component/localvc/vcs-repository-access-log-view.component.spec.ts @@ -0,0 +1,87 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ProgrammingExerciseParticipationService } from 'app/exercises/programming/manage/services/programming-exercise-participation.service'; +import { ActivatedRoute } from '@angular/router'; +import { MockProgrammingExerciseParticipationService } from '../../helpers/mocks/service/mock-programming-exercise-participation.service'; +import dayjs from 'dayjs/esm'; +import { of } from 'rxjs'; +import { TranslateService } from '@ngx-translate/core'; +import { ProgrammingExerciseService } from 'app/exercises/programming/manage/services/programming-exercise.service'; +import { MockProgrammingExerciseService } from '../../helpers/mocks/service/mock-programming-exercise.service'; +import { VcsRepositoryAccessLogViewComponent } from 'app/localvc/vcs-repository-access-log-view/vcs-repository-access-log-view.component'; +import { VcsAccessLogDTO } from 'app/entities/vcs-access-log-entry.model'; +import { MockTranslateService } from '../../helpers/mocks/service/mock-translate.service'; +import { AlertService } from 'app/core/util/alert.service'; +import { MockAlertService } from '../../helpers/mocks/service/mock-alert.service'; +import { MockProfileService } from '../../helpers/mocks/service/mock-profile.service'; +import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; + +describe('VcsRepositoryAccessLogViewComponent', () => { + let fixture: ComponentFixture; + let programmingExerciseParticipationService: ProgrammingExerciseParticipationService; + const userId = 4; + let participationVcsAccessLogSpy: jest.SpyInstance; + let repositoryVcsAccessLogSpy: jest.SpyInstance; + + const mockVcsAccessLog: VcsAccessLogDTO[] = [ + { + id: 1, + userId: userId, + name: 'authorName', + email: 'authorEmail', + commitHash: 'abcde', + authenticationMechanism: 'SSH', + repositoryActionType: 'WRITE', + timestamp: dayjs('2021-01-02'), + }, + { + id: 2, + userId: userId, + name: 'authorName', + email: 'authorEmail', + commitHash: 'fffee', + authenticationMechanism: 'SSH', + repositoryActionType: 'READ', + timestamp: dayjs('2021-01-03'), + }, + ]; + + const route = { params: of({ participationId: '5' }) } as any as ActivatedRoute; + + function setupTestBed() { + fixture = TestBed.createComponent(VcsRepositoryAccessLogViewComponent); + programmingExerciseParticipationService = fixture.debugElement.injector.get(ProgrammingExerciseParticipationService); + repositoryVcsAccessLogSpy = jest.spyOn(programmingExerciseParticipationService, 'getVcsAccessLogForRepository').mockReturnValue(of(mockVcsAccessLog)); + participationVcsAccessLogSpy = jest.spyOn(programmingExerciseParticipationService, 'getVcsAccessLogForParticipation').mockReturnValue(of(mockVcsAccessLog)); + } + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [VcsRepositoryAccessLogViewComponent], + providers: [ + { provide: ActivatedRoute, useValue: route }, + { provide: ProgrammingExerciseParticipationService, useClass: MockProgrammingExerciseParticipationService }, + { provide: ProgrammingExerciseService, useClass: MockProgrammingExerciseService }, + { provide: TranslateService, useClass: MockTranslateService }, + { provide: AlertService, useClass: MockAlertService }, + { provide: ProfileService, useClass: MockProfileService }, + ], + }).compileComponents(); + }); + + it('should load participation vcs access log', () => { + setupTestBed(); + fixture.detectChanges(); + + expect(participationVcsAccessLogSpy).toHaveBeenCalledOnce(); + }); + + it('should load template repository vcs access log', () => { + route.params = of({ exerciseId: '10', repositoryType: 'TEMPLATE' }); + TestBed.overrideProvider(ActivatedRoute, { useValue: route }); + + setupTestBed(); + fixture.detectChanges(); + + expect(repositoryVcsAccessLogSpy).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/test/javascript/spec/helpers/mocks/service/mock-programming-exercise-participation.service.ts b/src/test/javascript/spec/helpers/mocks/service/mock-programming-exercise-participation.service.ts index 8525ad43dc80..472de3e839b5 100644 --- a/src/test/javascript/spec/helpers/mocks/service/mock-programming-exercise-participation.service.ts +++ b/src/test/javascript/spec/helpers/mocks/service/mock-programming-exercise-participation.service.ts @@ -3,6 +3,7 @@ import { IProgrammingExerciseParticipationService } from 'app/exercises/programm import { ProgrammingExerciseStudentParticipation } from 'app/entities/participation/programming-exercise-student-participation.model'; import { Result } from 'app/entities/result.model'; import { CommitInfo } from 'app/entities/programming/programming-submission.model'; +import { VcsAccessLogDTO } from 'app/entities/vcs-access-log-entry.model'; export class MockProgrammingExerciseParticipationService implements IProgrammingExerciseParticipationService { getLatestResultWithFeedback = (participationId: number, withSubmission: boolean) => of({} as Result); @@ -13,4 +14,6 @@ export class MockProgrammingExerciseParticipationService implements IProgramming getParticipationRepositoryFilesWithContentAtCommitForCommitDetailsView = (exerciseId: number, participationId: number, commitId: string, repositoryType: string) => of(new Map()); checkIfParticipationHasResult = (participationId: number) => of(true); + getVcsAccessLogForRepository = (exerciseId: number, repositoryType: string) => of([] as VcsAccessLogDTO[]); + getVcsAccessLogForParticipation = (participationId: number) => of([] as VcsAccessLogDTO[]); } diff --git a/src/test/javascript/spec/service/programming-exercise-participation.service.spec.ts b/src/test/javascript/spec/service/programming-exercise-participation.service.spec.ts index 92bff0034faa..1ef9b69501d1 100644 --- a/src/test/javascript/spec/service/programming-exercise-participation.service.spec.ts +++ b/src/test/javascript/spec/service/programming-exercise-participation.service.spec.ts @@ -177,4 +177,21 @@ describe('ProgrammingExerciseParticipation Service', () => { req.flush(files); tick(); })); + + it('should make GET request to retrieve vcs access log for participation', fakeAsync(() => { + const participationId = 42; + service.getVcsAccessLogForParticipation(participationId).subscribe(); + const expectedURL = `${resourceUrlParticipations}${participationId}/vcs-access-log`; + httpMock.expectOne({ method: 'GET', url: expectedURL }); + tick(); + })); + + it('should make GET request to retrieve vcs access log for the template repository', fakeAsync(() => { + const exerciseId = 42; + const repositoryType = 'TEMPLATE'; + service.getVcsAccessLogForRepository(exerciseId, repositoryType).subscribe(); + const expectedURL = `${resourceUrl}${exerciseId}/vcs-access-log/${repositoryType}`; + httpMock.expectOne({ method: 'GET', url: expectedURL }); + tick(); + })); });