From 7be67f2c2852f00ab1fb329f7aa1787770dc5b77 Mon Sep 17 00:00:00 2001 From: tember8003 Date: Wed, 8 Apr 2026 15:56:25 +0900 Subject: [PATCH 1/2] =?UTF-8?q?Fix:=20=EA=B3=BC=EC=A0=9C=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assignment/repository/PlanRepository.java | 7 + .../repository/SubmissionRepository.java | 17 +- .../service/AssignmentSubmissionService.java | 21 +- .../AssignmentSubmissionConcurrencyTest.java | 186 +++++++++++++ .../AssignmentSubmissionServiceTest.java | 253 ++++++++++++++++++ 5 files changed, 472 insertions(+), 12 deletions(-) create mode 100644 src/test/java/com/mjsec/lms/assignment/service/AssignmentSubmissionConcurrencyTest.java create mode 100644 src/test/java/com/mjsec/lms/assignment/service/AssignmentSubmissionServiceTest.java diff --git a/src/main/java/com/mjsec/lms/assignment/repository/PlanRepository.java b/src/main/java/com/mjsec/lms/assignment/repository/PlanRepository.java index 816772e..0a81c7f 100644 --- a/src/main/java/com/mjsec/lms/assignment/repository/PlanRepository.java +++ b/src/main/java/com/mjsec/lms/assignment/repository/PlanRepository.java @@ -2,7 +2,9 @@ import com.mjsec.lms.assignment.domain.Plan; import com.mjsec.lms.user.domain.User; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -10,6 +12,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; @Repository public interface PlanRepository extends JpaRepository { @@ -43,6 +46,10 @@ List findAssignmentsExpiredBetween( @Param("endTime") LocalDateTime endTime ); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM Plan p WHERE p.planId = :planId") + Optional findByIdWithPessimisticLock(@Param("planId") Long planId); + @Modifying @Query("DELETE FROM Plan p WHERE p.creator = :user") void deleteByCreator(@Param("user") User user); diff --git a/src/main/java/com/mjsec/lms/assignment/repository/SubmissionRepository.java b/src/main/java/com/mjsec/lms/assignment/repository/SubmissionRepository.java index 6c65048..5bd3062 100644 --- a/src/main/java/com/mjsec/lms/assignment/repository/SubmissionRepository.java +++ b/src/main/java/com/mjsec/lms/assignment/repository/SubmissionRepository.java @@ -44,10 +44,17 @@ public interface SubmissionRepository extends JpaRepository findByPlanPlanIdAndSubmitterUserIdAndStatus(Long planId, Long submitterUserId, SubmissionStatus status); - int countByPlanPlanIdAndStatus(Long planId, SubmissionStatus status); - // 특정 과제에 제출한 고유 제출자 수 조회 (미제출자 계산용) - @Query("SELECT COUNT(DISTINCT a.submitter.userId) FROM AssignmentSubmission a WHERE a.plan.planId = :planId") - int countDistinctSubmittersByPlanId(@Param("planId") Long planId); - + @Query("SELECT COUNT(DISTINCT a.submitter.userId) FROM AssignmentSubmission a " + + "WHERE a.plan.planId = :planId " + + "AND a.submitter.userId IN (" + + "SELECT gm.user.userId FROM GroupMember gm " + + "WHERE gm.studyGroup.studyId = :groupId AND gm.role = com.mjsec.lms.studygroup.domain.type.GroupMemberRole.MENTEE" + + ")") + int countDistinctMenteeSubmittersByPlanIdAndGroupId(@Param("planId") Long planId, @Param("groupId") Long groupId); + + + @Query("SELECT COUNT(DISTINCT a.submitter.userId) FROM AssignmentSubmission a " + + "WHERE a.plan.planId = :planId AND a.status = :status") + int countDistinctSubmittersByPlanIdAndStatus(@Param("planId") Long planId, @Param("status") SubmissionStatus status); } diff --git a/src/main/java/com/mjsec/lms/assignment/service/AssignmentSubmissionService.java b/src/main/java/com/mjsec/lms/assignment/service/AssignmentSubmissionService.java index 0c1ce81..b774e84 100644 --- a/src/main/java/com/mjsec/lms/assignment/service/AssignmentSubmissionService.java +++ b/src/main/java/com/mjsec/lms/assignment/service/AssignmentSubmissionService.java @@ -10,6 +10,7 @@ import com.mjsec.lms.assignment.dto.SubmissionStatisticsResponse; import com.mjsec.lms.common.exception.RestApiException; import com.mjsec.lms.studygroup.repository.GroupMemberRepository; +import com.mjsec.lms.assignment.repository.PlanRepository; import com.mjsec.lms.assignment.repository.SubmissionRepository; import com.mjsec.lms.common.type.ErrorCode; import com.mjsec.lms.studygroup.domain.type.GroupMemberRole; @@ -31,12 +32,14 @@ public class AssignmentSubmissionService { private final ValidationUtils validationUtils; private final SubmissionRepository submissionRepository; private final GroupMemberRepository groupMemberRepository; + private final PlanRepository planRepository; - AssignmentSubmissionService(ValidationUtils validationUtils, SubmissionRepository submissionRepository, GroupMemberRepository groupMemberRepository){ + AssignmentSubmissionService(ValidationUtils validationUtils, SubmissionRepository submissionRepository, GroupMemberRepository groupMemberRepository, PlanRepository planRepository){ this.validationUtils = validationUtils; this.submissionRepository = submissionRepository; this.groupMemberRepository = groupMemberRepository; + this.planRepository = planRepository; } // 과제 제출하기 (멘티만) @@ -57,6 +60,10 @@ public SubmissionResponse submitAssignment(Long groupId, Long planId, Long curre //과제 기한 검사 // validationUtils.validateAssignmentDeadline(plan); + // Plan 행에 비관적 락 획득 — 동시 제출 요청을 직렬화하여 중복 제출 race condition 방지 + planRepository.findByIdWithPessimisticLock(planId) + .orElseThrow(() -> new RestApiException(ErrorCode.PLAN_NOT_FOUND)); + // 과제 제출 중복 여부 체크 validationUtils.validateDuplicateSubmission(user.getUserId(), planId); @@ -287,13 +294,13 @@ public SubmissionStatisticsResponse getSubmissionStatistics(Long groupId, Long p // 해당 스터디 그룹의 전체 멘티 수 조회 int totalMentees = groupMemberRepository.findByStudyGroup_StudyIdAndRole(groupId, GroupMemberRole.MENTEE).size(); - // 상태별 제출 수 조회 - int submittedCount = submissionRepository.countByPlanPlanIdAndStatus(planId, SubmissionStatus.SUBMITTED); - int completedCount = submissionRepository.countByPlanPlanIdAndStatus(planId, SubmissionStatus.COMPLETED); - int revisionRequiredCount = submissionRepository.countByPlanPlanIdAndStatus(planId, SubmissionStatus.REVISION_REQUIRED); + // 상태별 고유 제출자 수 조회 (중복 제출 시에도 정확한 인원 수 반영) + int submittedCount = submissionRepository.countDistinctSubmittersByPlanIdAndStatus(planId, SubmissionStatus.SUBMITTED); + int completedCount = submissionRepository.countDistinctSubmittersByPlanIdAndStatus(planId, SubmissionStatus.COMPLETED); + int revisionRequiredCount = submissionRepository.countDistinctSubmittersByPlanIdAndStatus(planId, SubmissionStatus.REVISION_REQUIRED); - // 미제출자 수 계산 - int submittedMentees = submissionRepository.countDistinctSubmittersByPlanId(planId); + // 미제출자 수 계산 (그룹 멘티 기준으로만 집계 - 음수 방지) + int submittedMentees = submissionRepository.countDistinctMenteeSubmittersByPlanIdAndGroupId(planId, groupId); int notSubmittedCount = totalMentees - submittedMentees; return SubmissionStatisticsResponse.builder() diff --git a/src/test/java/com/mjsec/lms/assignment/service/AssignmentSubmissionConcurrencyTest.java b/src/test/java/com/mjsec/lms/assignment/service/AssignmentSubmissionConcurrencyTest.java new file mode 100644 index 0000000..ad81a3a --- /dev/null +++ b/src/test/java/com/mjsec/lms/assignment/service/AssignmentSubmissionConcurrencyTest.java @@ -0,0 +1,186 @@ +package com.mjsec.lms.assignment.service; + +import com.mjsec.lms.assignment.domain.AssignmentSubmission; +import com.mjsec.lms.assignment.domain.Plan; +import com.mjsec.lms.assignment.dto.SubmissionDto; +import com.mjsec.lms.assignment.repository.PlanRepository; +import com.mjsec.lms.assignment.repository.SubmissionRepository; +import com.mjsec.lms.common.exception.RestApiException; +import com.mjsec.lms.common.type.ErrorCode; +import com.mjsec.lms.common.util.ValidationUtils; +import com.mjsec.lms.studygroup.repository.GroupMemberRepository; +import com.mjsec.lms.user.domain.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +/** + * 과제 동시 제출 Race Condition 방지 테스트 + * + * 비관적 락의 실제 블로킹 효과는 DB 트랜잭션 수준에서 동작하므로 + * 순수 단위 테스트로는 검증할 수 없다. + * + * 이 테스트는 다음 두 가지를 검증한다: + * 1. 서비스가 모든 동시 요청에서 락 획득 → 중복 체크 → 저장 순서를 유지하는가 + * 2. 중복 체크 로직(AtomicBoolean으로 DB의 원자적 동작을 시뮬레이션)이 + * 동시 요청 환경에서 정확히 1건만 통과시키는가 + * + * Mockito mock은 thread-safe하지 않으므로, 스레드 실행 전에 모든 stubbing을 + * 완료하고 실행 중에는 새로운 stubbing을 추가하지 않는다. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("AssignmentSubmissionService - 동시 제출 Race Condition 테스트") +class AssignmentSubmissionConcurrencyTest { + + @Mock private ValidationUtils validationUtils; + @Mock private SubmissionRepository submissionRepository; + @Mock private GroupMemberRepository groupMemberRepository; + @Mock private PlanRepository planRepository; + + private AssignmentSubmissionService service; + + private static final Long GROUP_ID = 1L; + private static final Long PLAN_ID = 10L; + private static final Long USER_ID = 100L; + private static final Long STUDENT_NUMBER = 20210001L; + private static final String IP = "127.0.0.1"; + + private User user; + private Plan plan; + + @BeforeEach + void setUp() { + service = new AssignmentSubmissionService( + validationUtils, submissionRepository, groupMemberRepository, planRepository + ); + + // 스레드 실행 전에 모든 stubbing을 완료한다. + // Mockito mock은 thread-safe하지 않으므로 실행 도중 stubbing 호출을 피한다. + user = mock(User.class); + plan = mock(Plan.class); + when(user.getUserId()).thenReturn(USER_ID); + when(user.getName()).thenReturn("홍길동"); + when(plan.getPlanId()).thenReturn(PLAN_ID); + + when(validationUtils.validateMenteeAccess(GROUP_ID, STUDENT_NUMBER)).thenReturn(user); + when(validationUtils.validatePlan(PLAN_ID)).thenReturn(plan); + when(planRepository.findByIdWithPessimisticLock(PLAN_ID)).thenReturn(Optional.of(plan)); + when(submissionRepository.save(any(AssignmentSubmission.class))).thenAnswer(i -> i.getArgument(0)); + } + + @Test + @DisplayName("동일 사용자가 동시에 N번 제출해도 정확히 1건만 성공한다") + void only_one_submission_succeeds_under_concurrent_requests() throws Exception { + final int threadCount = 5; + + // 비관적 락이 보장하는 효과를 시뮬레이션: + // 실제 DB에서는 락 덕분에 트랜잭션이 직렬화되어 두 번째 스레드가 + // 중복 체크를 수행할 때 이미 첫 번째 커밋 결과가 반영된다. + // AtomicBoolean의 compareAndSet이 동일한 원자성을 흉내낸다. + AtomicBoolean submitted = new AtomicBoolean(false); + doAnswer(invocation -> { + if (!submitted.compareAndSet(false, true)) { + throw new RestApiException(ErrorCode.DUPLICATE_SUBMISSION); + } + return null; + }).when(validationUtils).validateDuplicateSubmission(USER_ID, PLAN_ID); + + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + CountDownLatch startLatch = new CountDownLatch(1); + SubmissionDto dto = SubmissionDto.builder().content("https://blog.example.com").build(); + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + List> futures = new ArrayList<>(); + + for (int i = 0; i < threadCount; i++) { + futures.add(executor.submit(() -> { + try { + startLatch.await(); // 모든 스레드가 동시에 출발하도록 동기화 + service.submitAssignment(GROUP_ID, PLAN_ID, STUDENT_NUMBER, dto, IP); + successCount.incrementAndGet(); + } catch (RestApiException e) { + if (e.getErrorCode() == ErrorCode.DUPLICATE_SUBMISSION) { + failCount.incrementAndGet(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + })); + } + + startLatch.countDown(); // 전체 스레드 동시 출발 + for (Future f : futures) { + f.get(); + } + executor.shutdown(); + + assertThat(successCount.get()) + .as("정확히 1건만 제출에 성공해야 한다") + .isEqualTo(1); + assertThat(failCount.get()) + .as("나머지 %d건은 DUPLICATE_SUBMISSION으로 차단되어야 한다", threadCount - 1) + .isEqualTo(threadCount - 1); + } + + @Test + @DisplayName("동시 제출 시 비관적 락은 모든 요청에서 각각 호출된다") + void pessimistic_lock_is_called_for_every_concurrent_request() throws Exception { + final int threadCount = 3; + + AtomicBoolean submitted = new AtomicBoolean(false); + doAnswer(invocation -> { + if (!submitted.compareAndSet(false, true)) { + throw new RestApiException(ErrorCode.DUPLICATE_SUBMISSION); + } + return null; + }).when(validationUtils).validateDuplicateSubmission(anyLong(), anyLong()); + + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + SubmissionDto dto = SubmissionDto.builder().content("https://blog.example.com").build(); + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + startLatch.await(); + service.submitAssignment(GROUP_ID, PLAN_ID, STUDENT_NUMBER, dto, IP); + } catch (RestApiException | InterruptedException ignored) { + if (Thread.interrupted()) Thread.currentThread().interrupt(); + } finally { + doneLatch.countDown(); + } + }); + } + + startLatch.countDown(); + doneLatch.await(); // 모든 스레드가 끝날 때까지 대기 + executor.shutdown(); + + // 락 획득 시도는 요청 수만큼 반드시 발생해야 한다 + verify(planRepository, times(threadCount)).findByIdWithPessimisticLock(PLAN_ID); + } +} diff --git a/src/test/java/com/mjsec/lms/assignment/service/AssignmentSubmissionServiceTest.java b/src/test/java/com/mjsec/lms/assignment/service/AssignmentSubmissionServiceTest.java new file mode 100644 index 0000000..97f1357 --- /dev/null +++ b/src/test/java/com/mjsec/lms/assignment/service/AssignmentSubmissionServiceTest.java @@ -0,0 +1,253 @@ +package com.mjsec.lms.assignment.service; + +import com.mjsec.lms.assignment.domain.AssignmentSubmission; +import com.mjsec.lms.assignment.domain.Plan; +import com.mjsec.lms.assignment.dto.SubmissionDto; +import com.mjsec.lms.assignment.dto.SubmissionResponse; +import com.mjsec.lms.assignment.repository.PlanRepository; +import com.mjsec.lms.assignment.repository.SubmissionRepository; +import com.mjsec.lms.common.exception.RestApiException; +import com.mjsec.lms.common.type.ErrorCode; +import com.mjsec.lms.common.util.ValidationUtils; +import com.mjsec.lms.studygroup.repository.GroupMemberRepository; +import com.mjsec.lms.user.domain.User; +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.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +// mockUser() 헬퍼가 getName()/getUserId()를 미리 stub하는데, +// 예외 경로 테스트에서는 일부가 실제로 호출되지 않아 STRICT_STUBS에서 실패한다. +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("AssignmentSubmissionService - 과제 제출 단위 테스트") +class AssignmentSubmissionServiceTest { + + @Mock private ValidationUtils validationUtils; + @Mock private SubmissionRepository submissionRepository; + @Mock private GroupMemberRepository groupMemberRepository; + @Mock private PlanRepository planRepository; + + private AssignmentSubmissionService service; + + private static final Long GROUP_ID = 1L; + private static final Long PLAN_ID = 10L; + private static final Long USER_ID = 100L; + private static final Long STUDENT_NUMBER = 20210001L; + private static final String IP_ADDRESS = "127.0.0.1"; + + @BeforeEach + void setUp() { + service = new AssignmentSubmissionService( + validationUtils, submissionRepository, groupMemberRepository, planRepository + ); + } + + // ===================================================================== + // 정상 제출 흐름 + // ===================================================================== + + @Nested + @DisplayName("정상 제출") + class NormalSubmission { + + @Test + @DisplayName("모든 검증을 통과하면 과제가 저장되고 응답이 반환된다") + void submit_success() { + // given + User user = mockUser(USER_ID, "홍길동"); + Plan plan = mockPlan(PLAN_ID); + SubmissionDto dto = SubmissionDto.builder().content("https://blog.example.com").build(); + + when(validationUtils.validateMenteeAccess(GROUP_ID, STUDENT_NUMBER)).thenReturn(user); + when(validationUtils.validatePlan(PLAN_ID)).thenReturn(plan); + when(planRepository.findByIdWithPessimisticLock(PLAN_ID)).thenReturn(Optional.of(plan)); + when(submissionRepository.save(any(AssignmentSubmission.class))).thenAnswer(i -> i.getArgument(0)); + + // when + SubmissionResponse response = service.submitAssignment(GROUP_ID, PLAN_ID, STUDENT_NUMBER, dto, IP_ADDRESS); + + // then + assertThat(response).isNotNull(); + assertThat(response.getCreatorName()).isEqualTo("홍길동"); + verify(submissionRepository).save(any(AssignmentSubmission.class)); + } + + @Test + @DisplayName("비관적 락 획득이 반드시 중복 체크보다 먼저 수행된다 (순서 보장)") + void lock_acquired_before_duplicate_check() { + // given + User user = mockUser(USER_ID, "홍길동"); + Plan plan = mockPlan(PLAN_ID); + SubmissionDto dto = SubmissionDto.builder().content("https://blog.example.com").build(); + + when(validationUtils.validateMenteeAccess(GROUP_ID, STUDENT_NUMBER)).thenReturn(user); + when(validationUtils.validatePlan(PLAN_ID)).thenReturn(plan); + when(planRepository.findByIdWithPessimisticLock(PLAN_ID)).thenReturn(Optional.of(plan)); + when(submissionRepository.save(any(AssignmentSubmission.class))).thenAnswer(i -> i.getArgument(0)); + + // when + service.submitAssignment(GROUP_ID, PLAN_ID, STUDENT_NUMBER, dto, IP_ADDRESS); + + // then — 락 획득 → 중복 체크 순서가 반드시 지켜져야 한다 + InOrder inOrder = inOrder(planRepository, validationUtils); + inOrder.verify(planRepository).findByIdWithPessimisticLock(PLAN_ID); + inOrder.verify(validationUtils).validateDuplicateSubmission(USER_ID, PLAN_ID); + } + + @Test + @DisplayName("비관적 락은 매 제출 요청마다 정확히 1회 호출된다") + void lock_is_called_exactly_once_per_request() { + // given + User user = mockUser(USER_ID, "홍길동"); + Plan plan = mockPlan(PLAN_ID); + SubmissionDto dto = SubmissionDto.builder().content("https://blog.example.com").build(); + + when(validationUtils.validateMenteeAccess(GROUP_ID, STUDENT_NUMBER)).thenReturn(user); + when(validationUtils.validatePlan(PLAN_ID)).thenReturn(plan); + when(planRepository.findByIdWithPessimisticLock(PLAN_ID)).thenReturn(Optional.of(plan)); + when(submissionRepository.save(any(AssignmentSubmission.class))).thenAnswer(i -> i.getArgument(0)); + + // when + service.submitAssignment(GROUP_ID, PLAN_ID, STUDENT_NUMBER, dto, IP_ADDRESS); + + // then + verify(planRepository, times(1)).findByIdWithPessimisticLock(PLAN_ID); + } + } + + // ===================================================================== + // 비관적 락 획득 단계 예외 + // ===================================================================== + + @Nested + @DisplayName("비관적 락 획득 단계 예외") + class PessimisticLockFailure { + + @Test + @DisplayName("락 획득 시 plan이 존재하지 않으면 PLAN_NOT_FOUND 예외가 발생한다") + void plan_not_found_during_lock_throws_exception() { + // given + User user = mockUser(USER_ID, "홍길동"); + Plan plan = mockPlan(PLAN_ID); + SubmissionDto dto = SubmissionDto.builder().content("https://blog.example.com").build(); + + when(validationUtils.validateMenteeAccess(GROUP_ID, STUDENT_NUMBER)).thenReturn(user); + when(validationUtils.validatePlan(PLAN_ID)).thenReturn(plan); + when(planRepository.findByIdWithPessimisticLock(PLAN_ID)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> + service.submitAssignment(GROUP_ID, PLAN_ID, STUDENT_NUMBER, dto, IP_ADDRESS)) + .isInstanceOf(RestApiException.class) + .satisfies(e -> assertThat(((RestApiException) e).getErrorCode()) + .isEqualTo(ErrorCode.PLAN_NOT_FOUND)); + } + + @Test + @DisplayName("락 획득 실패 시 중복 체크와 저장이 수행되지 않는다") + void no_duplicate_check_or_save_when_lock_fails() { + // given + User user = mockUser(USER_ID, "홍길동"); + Plan plan = mockPlan(PLAN_ID); + SubmissionDto dto = SubmissionDto.builder().content("https://blog.example.com").build(); + + when(validationUtils.validateMenteeAccess(GROUP_ID, STUDENT_NUMBER)).thenReturn(user); + when(validationUtils.validatePlan(PLAN_ID)).thenReturn(plan); + when(planRepository.findByIdWithPessimisticLock(PLAN_ID)).thenReturn(Optional.empty()); + + // when + assertThatThrownBy(() -> + service.submitAssignment(GROUP_ID, PLAN_ID, STUDENT_NUMBER, dto, IP_ADDRESS)) + .isInstanceOf(RestApiException.class); + + // then — 락 실패 이후 단계는 실행되지 않아야 한다 + verify(validationUtils, never()).validateDuplicateSubmission(anyLong(), anyLong()); + verify(submissionRepository, never()).save(any()); + } + } + + // ===================================================================== + // 중복 제출 차단 + // ===================================================================== + + @Nested + @DisplayName("중복 제출 차단") + class DuplicateSubmission { + + @Test + @DisplayName("이미 제출한 사용자가 다시 제출하면 DUPLICATE_SUBMISSION 예외가 발생한다") + void duplicate_submission_throws_exception() { + // given + User user = mockUser(USER_ID, "홍길동"); + Plan plan = mockPlan(PLAN_ID); + SubmissionDto dto = SubmissionDto.builder().content("https://blog.example.com").build(); + + when(validationUtils.validateMenteeAccess(GROUP_ID, STUDENT_NUMBER)).thenReturn(user); + when(validationUtils.validatePlan(PLAN_ID)).thenReturn(plan); + when(planRepository.findByIdWithPessimisticLock(PLAN_ID)).thenReturn(Optional.of(plan)); + doThrow(new RestApiException(ErrorCode.DUPLICATE_SUBMISSION)) + .when(validationUtils).validateDuplicateSubmission(USER_ID, PLAN_ID); + + // when & then + assertThatThrownBy(() -> + service.submitAssignment(GROUP_ID, PLAN_ID, STUDENT_NUMBER, dto, IP_ADDRESS)) + .isInstanceOf(RestApiException.class) + .satisfies(e -> assertThat(((RestApiException) e).getErrorCode()) + .isEqualTo(ErrorCode.DUPLICATE_SUBMISSION)); + } + + @Test + @DisplayName("중복 제출이 차단되면 save가 호출되지 않는다") + void no_save_when_duplicate_detected() { + // given + User user = mockUser(USER_ID, "홍길동"); + Plan plan = mockPlan(PLAN_ID); + SubmissionDto dto = SubmissionDto.builder().content("https://blog.example.com").build(); + + when(validationUtils.validateMenteeAccess(GROUP_ID, STUDENT_NUMBER)).thenReturn(user); + when(validationUtils.validatePlan(PLAN_ID)).thenReturn(plan); + when(planRepository.findByIdWithPessimisticLock(PLAN_ID)).thenReturn(Optional.of(plan)); + doThrow(new RestApiException(ErrorCode.DUPLICATE_SUBMISSION)) + .when(validationUtils).validateDuplicateSubmission(anyLong(), anyLong()); + + // when + assertThatThrownBy(() -> + service.submitAssignment(GROUP_ID, PLAN_ID, STUDENT_NUMBER, dto, IP_ADDRESS)) + .isInstanceOf(RestApiException.class); + + // then + verify(submissionRepository, never()).save(any()); + } + } + + // ===================================================================== + // 헬퍼 + // ===================================================================== + + private User mockUser(Long userId, String name) { + User user = mock(User.class); + when(user.getUserId()).thenReturn(userId); + when(user.getName()).thenReturn(name); + return user; + } + + private Plan mockPlan(Long planId) { + Plan plan = mock(Plan.class); + when(plan.getPlanId()).thenReturn(planId); + return plan; + } +} From 698addc07b417c733de6571f67ebce1863b1d869 Mon Sep 17 00:00:00 2001 From: yuchan Date: Wed, 8 Apr 2026 21:02:58 +0900 Subject: [PATCH 2/2] =?UTF-8?q?Fix:=20security-spring=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/security-spring.yml | 37 ++++++++++++--------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/.github/workflows/security-spring.yml b/.github/workflows/security-spring.yml index 8d3cc7e..7717172 100644 --- a/.github/workflows/security-spring.yml +++ b/.github/workflows/security-spring.yml @@ -170,15 +170,14 @@ jobs: trivy --version - 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 - output: trivy-fs.sarif - exit-code: '0' + run: | + trivy fs \ + --ignore-unfixed \ + --severity HIGH,CRITICAL \ + --format sarif \ + --output trivy-fs.sarif \ + --exit-code 0 \ + . - name: Check Trivy FS SARIF has results id: trivyfs_sarif_check @@ -246,16 +245,14 @@ jobs: - name: Trivy image scan if: ${{ steps.df.outputs.found == 'true' }} - 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 - format: sarif - output: trivy-image.sarif - exit-code: '0' + run: | + trivy image \ + --ignore-unfixed \ + --severity HIGH,CRITICAL \ + --format sarif \ + --output trivy-image.sarif \ + --exit-code 0 \ + local/lms-back:secscan - name: Check Trivy IMG SARIF has results if: ${{ steps.df.outputs.found == 'true' }} @@ -304,4 +301,4 @@ jobs: if: ${{ steps.gitleaks_sarif_check.outputs.ok == 'true' }} uses: github/codeql-action/upload-sarif@v3 with: - sarif_file: gitleaks.sarif + sarif_file: gitleaks.sarif \ No newline at end of file