From 986c78cf175eeb6ecb5b26ead4507716474523a4 Mon Sep 17 00:00:00 2001 From: yuchan Date: Sun, 1 Mar 2026 15:25:08 +0900 Subject: [PATCH 1/5] =?UTF-8?q?Fix:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20-=20?= =?UTF-8?q?=EB=A9=98=ED=86=A0=20=EA=B6=8C=ED=95=9C=20API=20=ED=97=88?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lms/controller/AssignmentController.java | 6 +- .../com/mjsec/lms/service/MentorService.java | 8 +- .../com/mjsec/lms/util/ValidationUtils.java | 47 +-- .../service/MentorServiceAdminBypassTest.java | 336 +++++++++++++++++ .../util/ValidationUtilsAdminBypassTest.java | 340 ++++++++++++++++++ 5 files changed, 708 insertions(+), 29 deletions(-) create mode 100644 src/test/java/com/mjsec/lms/service/MentorServiceAdminBypassTest.java create mode 100644 src/test/java/com/mjsec/lms/util/ValidationUtilsAdminBypassTest.java diff --git a/src/main/java/com/mjsec/lms/controller/AssignmentController.java b/src/main/java/com/mjsec/lms/controller/AssignmentController.java index 879dbda..d71a6b2 100644 --- a/src/main/java/com/mjsec/lms/controller/AssignmentController.java +++ b/src/main/java/com/mjsec/lms/controller/AssignmentController.java @@ -1,6 +1,5 @@ package com.mjsec.lms.controller; -import com.mjsec.lms.domain.Plan; import com.mjsec.lms.domain.User; import com.mjsec.lms.dto.*; import com.mjsec.lms.service.AssignmentSubmissionService; @@ -11,7 +10,6 @@ import com.mjsec.lms.util.ValidationUtils; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; -import org.apache.coyote.Response; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @@ -108,7 +106,7 @@ public ResponseEntity> submitAssignment( Long currentUserStudentNumber = (Long) authentication.getPrincipal(); validationUtils.validatePlanBelongsToGroup(planId, groupId); - Plan plan = validationUtils.validatePlan(planId); + validationUtils.validatePlan(planId); //클라이언트 IP 뽑아내기 String clientIpAddr = IpUtils.getClientIp(request); @@ -184,7 +182,7 @@ public ResponseEntity> updateSubmissio Long currentUserStudentNumber = (Long) authentication.getPrincipal(); validationUtils.validateSubmissionFullAccess(groupId, planId, submitId); - Plan plan = validationUtils.validatePlan(planId); + validationUtils.validatePlan(planId); DetailSubmissionResponse detailSubmissionResponse = assignmentSubmissionService.updateAssignmentSubmission(groupId, planId, submitId, currentUserStudentNumber, dto); diff --git a/src/main/java/com/mjsec/lms/service/MentorService.java b/src/main/java/com/mjsec/lms/service/MentorService.java index fc4fb8f..a247e85 100644 --- a/src/main/java/com/mjsec/lms/service/MentorService.java +++ b/src/main/java/com/mjsec/lms/service/MentorService.java @@ -1,22 +1,20 @@ package com.mjsec.lms.service; import com.mjsec.lms.domain.GroupMember; -import com.mjsec.lms.domain.StudyActivity; import com.mjsec.lms.domain.StudyGroup; import com.mjsec.lms.domain.User; -import com.mjsec.lms.dto.StudyActivityDto; import com.mjsec.lms.dto.StudyGroupPutDto; import com.mjsec.lms.dto.StudyGroupPutResponse; import com.mjsec.lms.exception.RestApiException; import com.mjsec.lms.repository.AttendanceRepository; import com.mjsec.lms.repository.GroupMemberRepository; import com.mjsec.lms.repository.PlanCommentRepository; -import com.mjsec.lms.repository.PlanRepository; import com.mjsec.lms.repository.StudyActivityRepository; import com.mjsec.lms.repository.StudyGroupRepository; import com.mjsec.lms.repository.SubmissionRepository; import com.mjsec.lms.repository.UserRepository; import com.mjsec.lms.type.ErrorCode; +import com.mjsec.lms.type.UserRole; import java.util.List; import java.util.Objects; @@ -70,6 +68,10 @@ public StudyGroup checkMentor(Long currentStudentNumber, Long groupId) { StudyGroup studyGroup = studyGroupRepository.findByStudyId(groupId) .orElseThrow(() -> new RestApiException(ErrorCode.STUDY_NOT_FOUND)); + if (user.getRole() == UserRole.ROLE_ADMIN) { + return studyGroup; // 어드민은 creator 체크 없이 통과 + } + if(!Objects.equals(user.getUserId(), studyGroup.getCreator().getUserId())) { throw new RestApiException(ErrorCode.MENTOR_ONLY_CAN_DELETE_MEMBER); } diff --git a/src/main/java/com/mjsec/lms/util/ValidationUtils.java b/src/main/java/com/mjsec/lms/util/ValidationUtils.java index d805eac..0bf3493 100644 --- a/src/main/java/com/mjsec/lms/util/ValidationUtils.java +++ b/src/main/java/com/mjsec/lms/util/ValidationUtils.java @@ -112,12 +112,22 @@ public StudyActivity validateStudyActivity(Long activityId) { // 사용자가 해당 스터디 그룹의 멤버인지 확인 public void validateGroupMembership(User user, StudyGroup studyGroup) { + if (user.getRole() == UserRole.ROLE_ADMIN) { + return; // 어드민은 모든 그룹 접근 가능 + } groupMemberRepository.findByUserAndStudyGroup(user, studyGroup) .orElseThrow(() -> new RestApiException(ErrorCode.STUDY_USER_NOT_FOUND)); } // 사용자가 해당 스터디 그룹의 멘토인지 확인 public void validateMentorRole(Long userId, Long groupId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new RestApiException(ErrorCode.USER_NOT_FOUND)); + + if (user.getRole() == UserRole.ROLE_ADMIN) { + return; // 어드민은 멘토 권한 보유 + } + GroupMemberRole userRole = groupMemberRepository.findRoleByUserIdAndStudyId(userId, groupId) .orElseThrow(() -> new RestApiException(ErrorCode.STUDY_USER_NOT_FOUND)); @@ -188,9 +198,9 @@ public void validatePlanBelongsToGroup(Long planId, Long groupId) { } // 제출물의 완전한 연관관계 검증 - public AssignmentSubmission validateSubmissionFullAccess(Long groupId, Long planId, Long submitId) { + public void validateSubmissionFullAccess(Long groupId, Long planId, Long submitId) { validatePlanBelongsToGroup(planId, groupId); - return validateSubmissionAccess(planId, submitId); + validateSubmissionAccess(planId, submitId); } // 댓글 관리 권한 검증 강화 (작성자 본인 + 멘토 권한) @@ -300,27 +310,20 @@ public void validateSubmissionStatusForUpdate(AssignmentSubmission submission) { // 상태 전환이 유효한지 검증 public void validateStatusTransition(SubmissionStatus currentStatus, SubmissionStatus newStatus) { - boolean isValidTransition = false; - - switch (currentStatus) { - case SUBMITTED: + boolean isValidTransition = switch (currentStatus) { + case SUBMITTED -> // 제출 완료 -> 완료 또는 수정 필요 - isValidTransition = (newStatus == SubmissionStatus.COMPLETED || - newStatus == SubmissionStatus.REVISION_REQUIRED); - break; - - case REVISION_REQUIRED: + (newStatus == SubmissionStatus.COMPLETED || + newStatus == SubmissionStatus.REVISION_REQUIRED); + case REVISION_REQUIRED -> // 수정 필요 -> 제출 완료 (재제출 시) - isValidTransition = (newStatus == SubmissionStatus.SUBMITTED || - newStatus == SubmissionStatus.COMPLETED); - break; - - case COMPLETED: + (newStatus == SubmissionStatus.SUBMITTED || + newStatus == SubmissionStatus.COMPLETED); + case COMPLETED -> // 완료 -> 수정 필요 (피드백 수정 시에만) - isValidTransition = (newStatus == SubmissionStatus.REVISION_REQUIRED || - newStatus == SubmissionStatus.COMPLETED); // 피드백 수정 - break; - } + (newStatus == SubmissionStatus.REVISION_REQUIRED || + newStatus == SubmissionStatus.COMPLETED); // 피드백 수정 + }; if (!isValidTransition) { log.warn("Invalid status transition from {} to {}", currentStatus, newStatus); @@ -329,14 +332,14 @@ public void validateStatusTransition(SubmissionStatus currentStatus, SubmissionS } //과제 제출 상태 ENUM 타입 검증 - public SubmissionStatus validateSubmissionStatus(String statusString) { + public void validateSubmissionStatus(String statusString) { if (statusString == null || statusString.trim().isEmpty()) { throw new RestApiException(ErrorCode.INVALID_SUBMISSION_STATUS); } try { - return SubmissionStatus.valueOf(statusString.trim().toUpperCase()); + SubmissionStatus.valueOf(statusString.trim().toUpperCase()); } catch (IllegalArgumentException e) { log.warn("Invalid submission status provided: {}", statusString); throw new RestApiException(ErrorCode.INVALID_SUBMISSION_STATUS); diff --git a/src/test/java/com/mjsec/lms/service/MentorServiceAdminBypassTest.java b/src/test/java/com/mjsec/lms/service/MentorServiceAdminBypassTest.java new file mode 100644 index 0000000..771f865 --- /dev/null +++ b/src/test/java/com/mjsec/lms/service/MentorServiceAdminBypassTest.java @@ -0,0 +1,336 @@ +package com.mjsec.lms.service; + +import com.mjsec.lms.domain.GroupMember; +import com.mjsec.lms.domain.StudyGroup; +import com.mjsec.lms.domain.User; +import com.mjsec.lms.exception.RestApiException; +import com.mjsec.lms.repository.*; +import com.mjsec.lms.type.ErrorCode; +import com.mjsec.lms.type.UserRole; +import com.mjsec.lms.util.ValidationUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("MentorService - 어드민 bypass 테스트") +class MentorServiceAdminBypassTest { + + @Mock private UserRepository userRepository; + @Mock private StudyGroupRepository studyGroupRepository; + @Mock private GroupMemberRepository groupMemberRepository; + @Mock private SubmissionRepository submissionRepository; + @Mock private PlanCommentRepository planCommentRepository; + @Mock private AttendanceRepository attendanceRepository; + @Mock private StudyActivityRepository studyActivityRepository; + @Mock private ValidationUtils validationUtils; + @Mock private FileService fileService; + @Mock private PlanRepository planRepository; + + private MentorService mentorService; + + @BeforeEach + void setUp() { + mentorService = new MentorService( + userRepository, + studyGroupRepository, + groupMemberRepository, + submissionRepository, + planCommentRepository, + attendanceRepository, + studyActivityRepository, + validationUtils, + fileService + ); + } + + // ========== checkMentor() ========== + + @Nested + @DisplayName("checkMentor() - 멘토(creator) 확인") + class CheckMentorTest { + + @Test + @DisplayName("어드민은 그룹 creator가 아니어도 StudyGroup을 반환한다") + void admin_bypasses_creator_check() { + // given + Long adminStudentNumber = 99999L; + Long groupId = 10L; + + User adminUser = mock(User.class); + StudyGroup studyGroup = mock(StudyGroup.class); + + when(adminUser.getRole()).thenReturn(UserRole.ROLE_ADMIN); + when(userRepository.findByStudentNumber(adminStudentNumber)).thenReturn(Optional.of(adminUser)); + when(studyGroupRepository.findByStudyId(groupId)).thenReturn(Optional.of(studyGroup)); + + // when + StudyGroup result = mentorService.checkMentor(adminStudentNumber, groupId); + + // then + assertThat(result).isEqualTo(studyGroup); + + // creator 비교가 발생하지 않아야 함 + verify(studyGroup, never()).getCreator(); + } + + @Test + @DisplayName("일반 유저가 그룹 creator이면 StudyGroup을 반환한다") + void regular_user_passes_if_creator() { + // given + Long studentNumber = 20210001L; + Long groupId = 10L; + Long sharedUserId = 1L; + + User creator = mock(User.class); + StudyGroup studyGroup = mock(StudyGroup.class); + User creatorInGroup = mock(User.class); + + when(creator.getRole()).thenReturn(UserRole.ROLE_USER); + when(creator.getUserId()).thenReturn(sharedUserId); + when(creatorInGroup.getUserId()).thenReturn(sharedUserId); // 동일한 userId → creator 맞음 + when(studyGroup.getCreator()).thenReturn(creatorInGroup); + + when(userRepository.findByStudentNumber(studentNumber)).thenReturn(Optional.of(creator)); + when(studyGroupRepository.findByStudyId(groupId)).thenReturn(Optional.of(studyGroup)); + + // when + StudyGroup result = mentorService.checkMentor(studentNumber, groupId); + + // then + assertThat(result).isEqualTo(studyGroup); + } + + @Test + @DisplayName("일반 유저가 그룹 creator가 아니면 MENTOR_ONLY_CAN_DELETE_MEMBER 예외가 발생한다") + void regular_user_fails_if_not_creator() { + // given + Long studentNumber = 20210002L; + Long groupId = 10L; + + User regularUser = mock(User.class); + StudyGroup studyGroup = mock(StudyGroup.class); + User realCreator = mock(User.class); + + when(regularUser.getRole()).thenReturn(UserRole.ROLE_USER); + when(regularUser.getUserId()).thenReturn(2L); + when(realCreator.getUserId()).thenReturn(1L); // 다른 userId → creator 아님 + when(studyGroup.getCreator()).thenReturn(realCreator); + + when(userRepository.findByStudentNumber(studentNumber)).thenReturn(Optional.of(regularUser)); + when(studyGroupRepository.findByStudyId(groupId)).thenReturn(Optional.of(studyGroup)); + + // when & then + assertThatThrownBy(() -> mentorService.checkMentor(studentNumber, groupId)) + .isInstanceOf(RestApiException.class) + .satisfies(e -> assertThat(((RestApiException) e).getErrorCode()) + .isEqualTo(ErrorCode.MENTOR_ONLY_CAN_DELETE_MEMBER)); + } + + @Test + @DisplayName("존재하지 않는 유저면 USER_NOT_FOUND 예외가 발생한다") + void user_not_found_throws_exception() { + // given + Long studentNumber = 99998L; + Long groupId = 10L; + when(userRepository.findByStudentNumber(studentNumber)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> mentorService.checkMentor(studentNumber, groupId)) + .isInstanceOf(RestApiException.class) + .satisfies(e -> assertThat(((RestApiException) e).getErrorCode()) + .isEqualTo(ErrorCode.USER_NOT_FOUND)); + } + + @Test + @DisplayName("존재하지 않는 그룹이면 STUDY_NOT_FOUND 예외가 발생한다") + void group_not_found_throws_exception() { + // given + Long studentNumber = 20210001L; + Long groupId = 999L; + + User user = mock(User.class); + when(userRepository.findByStudentNumber(studentNumber)).thenReturn(Optional.of(user)); + when(studyGroupRepository.findByStudyId(groupId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> mentorService.checkMentor(studentNumber, groupId)) + .isInstanceOf(RestApiException.class) + .satisfies(e -> assertThat(((RestApiException) e).getErrorCode()) + .isEqualTo(ErrorCode.STUDY_NOT_FOUND)); + } + } + + // ========== warnMember() ========== + + @Nested + @DisplayName("warnMember() - 멘티 경고 부여") + class WarnMemberTest { + + @Test + @DisplayName("어드민은 creator가 아니어도 멘티에게 경고를 부여할 수 있다") + void admin_can_warn_member() { + // given + Long adminStudentNumber = 99999L; + Long groupId = 10L; + Long menteeStudentNumber = 20210001L; + + User adminUser = mock(User.class); + StudyGroup studyGroup = mock(StudyGroup.class); + User mentee = mock(User.class); + GroupMember groupMember = mock(GroupMember.class); + + when(adminUser.getRole()).thenReturn(UserRole.ROLE_ADMIN); + when(userRepository.findByStudentNumber(adminStudentNumber)).thenReturn(Optional.of(adminUser)); + when(studyGroupRepository.findByStudyId(groupId)).thenReturn(Optional.of(studyGroup)); + when(userRepository.findByStudentNumber(menteeStudentNumber)).thenReturn(Optional.of(mentee)); + when(groupMemberRepository.findByUserAndStudyGroup(mentee, studyGroup)).thenReturn(Optional.of(groupMember)); + when(groupMember.getWarn()).thenReturn(1); + + // when & then + assertThatCode(() -> mentorService.warnMember(adminStudentNumber, groupId, menteeStudentNumber)) + .doesNotThrowAnyException(); + + verify(groupMember).setWarn(2); + verify(groupMemberRepository).save(groupMember); + } + + @Test + @DisplayName("경고 3회 누적 시 멤버가 자동 퇴출된다") + void admin_warn_triggers_expulsion_at_3_warnings() { + // given + Long adminStudentNumber = 99999L; + Long groupId = 10L; + Long menteeStudentNumber = 20210001L; + + User adminUser = mock(User.class); + StudyGroup studyGroup = mock(StudyGroup.class); + User mentee = mock(User.class); + GroupMember groupMember = mock(GroupMember.class); + + when(adminUser.getRole()).thenReturn(UserRole.ROLE_ADMIN); + when(userRepository.findByStudentNumber(adminStudentNumber)).thenReturn(Optional.of(adminUser)); + when(studyGroupRepository.findByStudyId(groupId)).thenReturn(Optional.of(studyGroup)); + when(userRepository.findByStudentNumber(menteeStudentNumber)).thenReturn(Optional.of(mentee)); + when(groupMemberRepository.findByUserAndStudyGroup(mentee, studyGroup)).thenReturn(Optional.of(groupMember)); + when(groupMember.getWarn()).thenReturn(3); // 경고 3회 누적 + + // when + mentorService.warnMember(adminStudentNumber, groupId, menteeStudentNumber); + + // then - 3회 누적이므로 자동 퇴출 + verify(groupMemberRepository).delete(groupMember); + } + } + + // ========== addMember() ========== + + @Nested + @DisplayName("addMember() - 멤버 추가") + class AddMemberTest { + + @Test + @DisplayName("어드민은 creator가 아니어도 멤버를 추가할 수 있다") + void admin_can_add_member() { + // given + Long adminStudentNumber = 99999L; + Long groupId = 10L; + Long newMenteeStudentNumber = 20210002L; + + User adminUser = mock(User.class); + StudyGroup studyGroup = mock(StudyGroup.class); + User newMentee = mock(User.class); + + when(adminUser.getRole()).thenReturn(UserRole.ROLE_ADMIN); + when(userRepository.findByStudentNumber(adminStudentNumber)).thenReturn(Optional.of(adminUser)); + when(studyGroupRepository.findByStudyId(groupId)).thenReturn(Optional.of(studyGroup)); + when(userRepository.findByStudentNumber(newMenteeStudentNumber)).thenReturn(Optional.of(newMentee)); + when(groupMemberRepository.existsByUserAndStudyGroup(newMentee, studyGroup)).thenReturn(false); + + // when & then + assertThatCode(() -> mentorService.addMember(adminStudentNumber, groupId, newMenteeStudentNumber)) + .doesNotThrowAnyException(); + + verify(groupMemberRepository).save(any(GroupMember.class)); + } + + @Test + @DisplayName("이미 그룹에 있는 멤버를 추가하면 ALREADY_JOINED_GROUP 예외가 발생한다") + void add_already_joined_member_throws_exception() { + // given + Long adminStudentNumber = 99999L; + Long groupId = 10L; + Long existingMenteeStudentNumber = 20210003L; + + User adminUser = mock(User.class); + StudyGroup studyGroup = mock(StudyGroup.class); + User existingMentee = mock(User.class); + + when(adminUser.getRole()).thenReturn(UserRole.ROLE_ADMIN); + when(userRepository.findByStudentNumber(adminStudentNumber)).thenReturn(Optional.of(adminUser)); + when(studyGroupRepository.findByStudyId(groupId)).thenReturn(Optional.of(studyGroup)); + when(userRepository.findByStudentNumber(existingMenteeStudentNumber)).thenReturn(Optional.of(existingMentee)); + when(groupMemberRepository.existsByUserAndStudyGroup(existingMentee, studyGroup)).thenReturn(true); + + // when & then + assertThatThrownBy(() -> mentorService.addMember(adminStudentNumber, groupId, existingMenteeStudentNumber)) + .isInstanceOf(RestApiException.class) + .satisfies(e -> assertThat(((RestApiException) e).getErrorCode()) + .isEqualTo(ErrorCode.ALREADY_JOINED_GROUP)); + } + } + + // ========== deleteMember() ========== + + @Nested + @DisplayName("deleteMember() - 멤버 삭제") + class DeleteMemberTest { + + @Test + @DisplayName("어드민은 creator가 아니어도 멤버를 삭제할 수 있다") + void admin_can_delete_member() { + // given + Long adminStudentNumber = 99999L; + Long groupId = 10L; + Long menteeStudentNumber = 20210004L; + Long menteeUserId = 5L; + Long studyGroupId = 10L; + + User adminUser = mock(User.class); + StudyGroup studyGroup = mock(StudyGroup.class); + User mentee = mock(User.class); + GroupMember groupMember = mock(GroupMember.class); + + when(adminUser.getRole()).thenReturn(UserRole.ROLE_ADMIN); + when(mentee.getUserId()).thenReturn(menteeUserId); + when(studyGroup.getStudyId()).thenReturn(studyGroupId); + + when(userRepository.findByStudentNumber(adminStudentNumber)).thenReturn(Optional.of(adminUser)); + when(studyGroupRepository.findByStudyId(groupId)).thenReturn(Optional.of(studyGroup)); + when(userRepository.findByStudentNumber(menteeStudentNumber)).thenReturn(Optional.of(mentee)); + when(groupMemberRepository.findByUserAndStudyGroup(mentee, studyGroup)).thenReturn(Optional.of(groupMember)); + + // 연관 데이터 없음 (cascade 삭제 대상 없음) + when(submissionRepository.findIdsByUserIdAndStudyGroupId(menteeUserId, studyGroupId)).thenReturn(List.of()); + when(planCommentRepository.findIdsByUserIdAndStudyGroupId(menteeUserId, studyGroupId)).thenReturn(List.of()); + when(attendanceRepository.findIdsByUserIdAndStudyGroupId(menteeUserId, studyGroupId)).thenReturn(List.of()); + when(studyActivityRepository.findIdsByCreatorIdAndStudyGroupId(menteeUserId, studyGroupId)).thenReturn(List.of()); + + // when & then + assertThatCode(() -> mentorService.deleteMember(adminStudentNumber, groupId, menteeStudentNumber)) + .doesNotThrowAnyException(); + + verify(groupMemberRepository).delete(groupMember); + } + } +} diff --git a/src/test/java/com/mjsec/lms/util/ValidationUtilsAdminBypassTest.java b/src/test/java/com/mjsec/lms/util/ValidationUtilsAdminBypassTest.java new file mode 100644 index 0000000..5b87b81 --- /dev/null +++ b/src/test/java/com/mjsec/lms/util/ValidationUtilsAdminBypassTest.java @@ -0,0 +1,340 @@ +package com.mjsec.lms.util; + +import com.mjsec.lms.domain.GroupMember; +import com.mjsec.lms.domain.StudyGroup; +import com.mjsec.lms.domain.User; +import com.mjsec.lms.exception.RestApiException; +import com.mjsec.lms.repository.*; +import com.mjsec.lms.type.ErrorCode; +import com.mjsec.lms.type.GroupMemberRole; +import com.mjsec.lms.type.UserRole; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ValidationUtils - 어드민 권한 bypass 테스트") +class ValidationUtilsAdminBypassTest { + + @Mock private UserRepository userRepository; + @Mock private StudyGroupRepository studyGroupRepository; + @Mock private GroupMemberRepository groupMemberRepository; + @Mock private AttendanceRepository attendanceRepository; + @Mock private PlanRepository planRepository; + @Mock private SubmissionRepository submissionRepository; + @Mock private PlanCommentRepository planCommentRepository; + @Mock private StudyActivityRepository studyActivityRepository; + + private ValidationUtils validationUtils; + + @BeforeEach + void setUp() { + validationUtils = new ValidationUtils( + userRepository, + studyGroupRepository, + groupMemberRepository, + attendanceRepository, + planRepository, + submissionRepository, + planCommentRepository, + studyActivityRepository + ); + } + + // ========== validateGroupMembership() ========== + + @Nested + @DisplayName("validateGroupMembership() - 그룹 멤버십 검증") + class ValidateGroupMembershipTest { + + @Test + @DisplayName("어드민은 그룹 멤버가 아니어도 통과한다") + void admin_bypasses_membership_check() { + // given + User adminUser = mock(User.class); + StudyGroup studyGroup = mock(StudyGroup.class); + when(adminUser.getRole()).thenReturn(UserRole.ROLE_ADMIN); + + // when & then + assertThatCode(() -> validationUtils.validateGroupMembership(adminUser, studyGroup)) + .doesNotThrowAnyException(); + + // DB 조회 자체가 발생하지 않아야 함 + verifyNoInteractions(groupMemberRepository); + } + + @Test + @DisplayName("일반 유저는 그룹 멤버이면 통과한다") + void regular_user_passes_if_member() { + // given + User regularUser = mock(User.class); + StudyGroup studyGroup = mock(StudyGroup.class); + when(regularUser.getRole()).thenReturn(UserRole.ROLE_USER); + when(groupMemberRepository.findByUserAndStudyGroup(regularUser, studyGroup)) + .thenReturn(Optional.of(mock(GroupMember.class))); + + // when & then + assertThatCode(() -> validationUtils.validateGroupMembership(regularUser, studyGroup)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("일반 유저는 그룹 멤버가 아니면 STUDY_USER_NOT_FOUND 예외가 발생한다") + void regular_user_fails_if_not_member() { + // given + User regularUser = mock(User.class); + StudyGroup studyGroup = mock(StudyGroup.class); + when(regularUser.getRole()).thenReturn(UserRole.ROLE_USER); + when(groupMemberRepository.findByUserAndStudyGroup(regularUser, studyGroup)) + .thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> validationUtils.validateGroupMembership(regularUser, studyGroup)) + .isInstanceOf(RestApiException.class) + .satisfies(e -> assertThat(((RestApiException) e).getErrorCode()) + .isEqualTo(ErrorCode.STUDY_USER_NOT_FOUND)); + } + } + + // ========== validateBasicAccess() ========== + + @Nested + @DisplayName("validateBasicAccess() - 기본 접근 검증 (복합 흐름)") + class ValidateBasicAccessTest { + + @Test + @DisplayName("어드민은 그룹 멤버가 아니어도 validateBasicAccess를 통과한다") + void admin_bypasses_basic_access() { + // given + Long adminStudentNumber = 99999L; + Long groupId = 10L; + + User adminUser = mock(User.class); + StudyGroup studyGroup = mock(StudyGroup.class); + + when(adminUser.getRole()).thenReturn(UserRole.ROLE_ADMIN); + when(userRepository.findByStudentNumber(adminStudentNumber)).thenReturn(Optional.of(adminUser)); + when(studyGroupRepository.findById(groupId)).thenReturn(Optional.of(studyGroup)); + + // when & then + assertThatCode(() -> validationUtils.validateBasicAccess(groupId, adminStudentNumber)) + .doesNotThrowAnyException(); + + // 멤버십 DB 조회가 발생하지 않아야 함 + verifyNoInteractions(groupMemberRepository); + } + + @Test + @DisplayName("일반 유저는 그룹 멤버이면 validateBasicAccess를 통과한다") + void regular_user_passes_basic_access_if_member() { + // given + Long studentNumber = 20210001L; + Long groupId = 10L; + + User regularUser = mock(User.class); + StudyGroup studyGroup = mock(StudyGroup.class); + + when(regularUser.getRole()).thenReturn(UserRole.ROLE_USER); + when(userRepository.findByStudentNumber(studentNumber)).thenReturn(Optional.of(regularUser)); + when(studyGroupRepository.findById(groupId)).thenReturn(Optional.of(studyGroup)); + when(groupMemberRepository.findByUserAndStudyGroup(regularUser, studyGroup)) + .thenReturn(Optional.of(mock(GroupMember.class))); + + // when & then + assertThatCode(() -> validationUtils.validateBasicAccess(groupId, studentNumber)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("일반 유저는 그룹 멤버가 아니면 validateBasicAccess에서 예외가 발생한다") + void regular_user_fails_basic_access_if_not_member() { + // given + Long studentNumber = 20210002L; + Long groupId = 10L; + + User regularUser = mock(User.class); + StudyGroup studyGroup = mock(StudyGroup.class); + + when(regularUser.getRole()).thenReturn(UserRole.ROLE_USER); + when(userRepository.findByStudentNumber(studentNumber)).thenReturn(Optional.of(regularUser)); + when(studyGroupRepository.findById(groupId)).thenReturn(Optional.of(studyGroup)); + when(groupMemberRepository.findByUserAndStudyGroup(regularUser, studyGroup)) + .thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> validationUtils.validateBasicAccess(groupId, studentNumber)) + .isInstanceOf(RestApiException.class) + .satisfies(e -> assertThat(((RestApiException) e).getErrorCode()) + .isEqualTo(ErrorCode.STUDY_USER_NOT_FOUND)); + } + } + + // ========== validateMentorAccess() ========== + + @Nested + @DisplayName("validateMentorAccess() - 멘토 접근 검증 (복합 흐름)") + class ValidateMentorAccessTest { + + @Test + @DisplayName("어드민은 그룹 멤버가 아니어도 validateMentorAccess를 통과한다") + void admin_bypasses_mentor_access() { + // given + Long adminStudentNumber = 99999L; + Long adminUserId = 1L; + Long groupId = 10L; + + User adminUser = mock(User.class); + StudyGroup studyGroup = mock(StudyGroup.class); + + when(adminUser.getRole()).thenReturn(UserRole.ROLE_ADMIN); + when(adminUser.getUserId()).thenReturn(adminUserId); + when(userRepository.findByStudentNumber(adminStudentNumber)).thenReturn(Optional.of(adminUser)); + when(studyGroupRepository.findById(groupId)).thenReturn(Optional.of(studyGroup)); + // validateMentorRole 내부에서 findById 한 번 더 호출됨 + when(userRepository.findById(adminUserId)).thenReturn(Optional.of(adminUser)); + + // when & then + assertThatCode(() -> validationUtils.validateMentorAccess(groupId, adminStudentNumber)) + .doesNotThrowAnyException(); + + // 멤버 역할 조회가 발생하지 않아야 함 + verify(groupMemberRepository, never()).findRoleByUserIdAndStudyId(any(), any()); + } + + @Test + @DisplayName("일반 유저가 MENTOR이면 validateMentorAccess를 통과한다") + void regular_mentor_passes_mentor_access() { + // given + Long studentNumber = 20210001L; + Long userId = 2L; + Long groupId = 10L; + + User mentorUser = mock(User.class); + StudyGroup studyGroup = mock(StudyGroup.class); + + when(mentorUser.getRole()).thenReturn(UserRole.ROLE_USER); + when(mentorUser.getUserId()).thenReturn(userId); + when(userRepository.findByStudentNumber(studentNumber)).thenReturn(Optional.of(mentorUser)); + when(studyGroupRepository.findById(groupId)).thenReturn(Optional.of(studyGroup)); + when(userRepository.findById(userId)).thenReturn(Optional.of(mentorUser)); + when(groupMemberRepository.findRoleByUserIdAndStudyId(userId, groupId)) + .thenReturn(Optional.of(GroupMemberRole.MENTOR)); + + // when & then + assertThatCode(() -> validationUtils.validateMentorAccess(groupId, studentNumber)) + .doesNotThrowAnyException(); + } + } + + // ========== validateMenteeRole() 리그레션 ========== + + @Nested + @DisplayName("validateMenteeRole() - 멘티 역할 검증 (리그레션: 어드민도 막혀야 함)") + class ValidateMenteeRoleRegressionTest { + + @Test + @DisplayName("어드민도 그룹 멤버가 아니면 멘티 역할 체크에서 STUDY_USER_NOT_FOUND 예외가 발생한다") + void admin_is_blocked_by_mentee_role_check() { + // given - 어드민은 group_member 테이블에 없으므로 empty 반환 + Long adminUserId = 1L; + Long groupId = 10L; + when(groupMemberRepository.findRoleByUserIdAndStudyId(adminUserId, groupId)) + .thenReturn(Optional.empty()); + + // when & then - 과제 제출/수정/삭제 같은 멘티 전용 작업은 어드민도 불가 + assertThatThrownBy(() -> validationUtils.validateMenteeRole(adminUserId, groupId)) + .isInstanceOf(RestApiException.class) + .satisfies(e -> assertThat(((RestApiException) e).getErrorCode()) + .isEqualTo(ErrorCode.STUDY_USER_NOT_FOUND)); + } + } + + // ========== validateMentorRole() ========== + + @Nested + @DisplayName("validateMentorRole() - 멘토 역할 검증") + class ValidateMentorRoleTest { + + @Test + @DisplayName("어드민은 그룹 멤버가 아니어도 멘토 검증을 통과한다") + void admin_bypasses_mentor_role_check() { + // given + Long userId = 1L; + Long groupId = 10L; + User adminUser = mock(User.class); + when(adminUser.getRole()).thenReturn(UserRole.ROLE_ADMIN); + when(userRepository.findById(userId)).thenReturn(Optional.of(adminUser)); + + // when & then + assertThatCode(() -> validationUtils.validateMentorRole(userId, groupId)) + .doesNotThrowAnyException(); + + // role 조회 DB 호출이 발생하지 않아야 함 + verify(groupMemberRepository, never()).findRoleByUserIdAndStudyId(any(), any()); + } + + @Test + @DisplayName("일반 유저가 MENTOR 역할이면 통과한다") + void regular_user_passes_if_mentor() { + // given + Long userId = 2L; + Long groupId = 10L; + User regularUser = mock(User.class); + when(regularUser.getRole()).thenReturn(UserRole.ROLE_USER); + when(userRepository.findById(userId)).thenReturn(Optional.of(regularUser)); + when(groupMemberRepository.findRoleByUserIdAndStudyId(userId, groupId)) + .thenReturn(Optional.of(GroupMemberRole.MENTOR)); + + // when & then + assertThatCode(() -> validationUtils.validateMentorRole(userId, groupId)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("일반 유저가 MENTEE 역할이면 UNAUTHORIZED_MENTOR_ROLE 예외가 발생한다") + void regular_user_fails_if_mentee() { + // given + Long userId = 3L; + Long groupId = 10L; + User regularUser = mock(User.class); + when(regularUser.getRole()).thenReturn(UserRole.ROLE_USER); + when(userRepository.findById(userId)).thenReturn(Optional.of(regularUser)); + when(groupMemberRepository.findRoleByUserIdAndStudyId(userId, groupId)) + .thenReturn(Optional.of(GroupMemberRole.MENTEE)); + + // when & then + assertThatThrownBy(() -> validationUtils.validateMentorRole(userId, groupId)) + .isInstanceOf(RestApiException.class) + .satisfies(e -> assertThat(((RestApiException) e).getErrorCode()) + .isEqualTo(ErrorCode.UNAUTHORIZED_MENTOR_ROLE)); + } + + @Test + @DisplayName("일반 유저가 그룹 멤버가 아니면 STUDY_USER_NOT_FOUND 예외가 발생한다") + void regular_user_fails_if_not_in_group() { + // given + Long userId = 4L; + Long groupId = 10L; + User regularUser = mock(User.class); + when(regularUser.getRole()).thenReturn(UserRole.ROLE_USER); + when(userRepository.findById(userId)).thenReturn(Optional.of(regularUser)); + when(groupMemberRepository.findRoleByUserIdAndStudyId(userId, groupId)) + .thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> validationUtils.validateMentorRole(userId, groupId)) + .isInstanceOf(RestApiException.class) + .satisfies(e -> assertThat(((RestApiException) e).getErrorCode()) + .isEqualTo(ErrorCode.STUDY_USER_NOT_FOUND)); + } + } +} From 8362cddcdb43c510de8552ae50b7a24f6077de93 Mon Sep 17 00:00:00 2001 From: yuchan Date: Sun, 1 Mar 2026 15:48:36 +0900 Subject: [PATCH 2/5] =?UTF-8?q?Fix:=20trivy-action=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/security-spring.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/security-spring.yml b/.github/workflows/security-spring.yml index caedf8f..436fe69 100644 --- a/.github/workflows/security-spring.yml +++ b/.github/workflows/security-spring.yml @@ -160,7 +160,7 @@ jobs: # Trivy (filesystem scan) → SARIF # ────────────────────────────── - name: Trivy filesystem scan - uses: aquasecurity/trivy-action@0.28.0 + uses: aquasecurity/trivy-action@0.34.1 with: scan-type: fs ignore-unfixed: true @@ -235,7 +235,7 @@ jobs: - name: Trivy image scan if: ${{ steps.df.outputs.found == 'true' }} - uses: aquasecurity/trivy-action@0.28.0 + uses: aquasecurity/trivy-action@0.34.1 with: scan-type: image image-ref: local/lms-back:secscan From 4711391752ea355e7810f1fc34fc4de004716ff2 Mon Sep 17 00:00:00 2001 From: yuchan Date: Sun, 1 Mar 2026 16:33:07 +0900 Subject: [PATCH 3/5] =?UTF-8?q?Fix:=20Trivy=20=EC=84=A4=EC=B9=98=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EB=B3=80=EA=B2=BD=20-=20setup-trivy=20mai?= =?UTF-8?q?n=20=EB=B8=8C=EB=9E=9C=EC=B9=98=20=EC=98=A4=EB=A5=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/security-spring.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/security-spring.yml b/.github/workflows/security-spring.yml index 436fe69..c5c49ed 100644 --- a/.github/workflows/security-spring.yml +++ b/.github/workflows/security-spring.yml @@ -159,10 +159,17 @@ jobs: # ────────────────────────────── # Trivy (filesystem scan) → SARIF # ────────────────────────────── + - name: Install Trivy + run: | + curl -sL https://github.com/aquasecurity/trivy/releases/download/v0.69.1/trivy_0.69.1_Linux-64bit.tar.gz \ + | tar -xz -C /tmp trivy + sudo mv /tmp/trivy /usr/local/bin/trivy + - name: Trivy filesystem scan uses: aquasecurity/trivy-action@0.34.1 with: scan-type: fs + skip-setup-trivy: true ignore-unfixed: true severity: HIGH,CRITICAL format: sarif @@ -238,6 +245,7 @@ jobs: uses: aquasecurity/trivy-action@0.34.1 with: scan-type: image + skip-setup-trivy: true image-ref: local/lms-back:secscan ignore-unfixed: true severity: HIGH,CRITICAL From 6ee2cefa4585eb13d59bf603a9b24f5e5cdf8040 Mon Sep 17 00:00:00 2001 From: yuchan Date: Sun, 1 Mar 2026 16:41:30 +0900 Subject: [PATCH 4/5] =?UTF-8?q?Fix:=20gh=20release=20download=EB=A1=9C=20T?= =?UTF-8?q?rivy=20=EC=84=A4=EC=B9=98=20=EB=B0=A9=EC=8B=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/security-spring.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/security-spring.yml b/.github/workflows/security-spring.yml index c5c49ed..8d64fa0 100644 --- a/.github/workflows/security-spring.yml +++ b/.github/workflows/security-spring.yml @@ -160,10 +160,15 @@ jobs: # Trivy (filesystem scan) → SARIF # ────────────────────────────── - name: Install Trivy + env: + GH_TOKEN: ${{ github.token }} run: | - curl -sL https://github.com/aquasecurity/trivy/releases/download/v0.69.1/trivy_0.69.1_Linux-64bit.tar.gz \ - | tar -xz -C /tmp trivy + gh release download --repo aquasecurity/trivy \ + --pattern "trivy_*_Linux-64bit.tar.gz" \ + --dir /tmp + tar -xzf /tmp/trivy_*_Linux-64bit.tar.gz -C /tmp trivy sudo mv /tmp/trivy /usr/local/bin/trivy + trivy --version - name: Trivy filesystem scan uses: aquasecurity/trivy-action@0.34.1 From 95074c93c7e6bc325e4a0a8682afa254a94bf056 Mon Sep 17 00:00:00 2001 From: yuchan Date: Sun, 1 Mar 2026 16:50:41 +0900 Subject: [PATCH 5/5] =?UTF-8?q?Fix:=20apt=EB=A1=9C=20Trivy=20=EC=84=A4?= =?UTF-8?q?=EC=B9=98=20=EB=B0=A9=EC=8B=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/security-spring.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/security-spring.yml b/.github/workflows/security-spring.yml index 8d64fa0..8d3cc7e 100644 --- a/.github/workflows/security-spring.yml +++ b/.github/workflows/security-spring.yml @@ -160,14 +160,13 @@ jobs: # Trivy (filesystem scan) → SARIF # ────────────────────────────── - name: Install Trivy - env: - GH_TOKEN: ${{ github.token }} run: | - gh release download --repo aquasecurity/trivy \ - --pattern "trivy_*_Linux-64bit.tar.gz" \ - --dir /tmp - tar -xzf /tmp/trivy_*_Linux-64bit.tar.gz -C /tmp trivy - sudo mv /tmp/trivy /usr/local/bin/trivy + wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key \ + | gpg --dearmor \ + | sudo tee /etc/apt/trusted.gpg.d/trivy.gpg > /dev/null + echo "deb https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main" \ + | sudo tee /etc/apt/sources.list.d/trivy.list + sudo apt-get update -qq && sudo apt-get install -y trivy trivy --version - name: Trivy filesystem scan