Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 17 additions & 20 deletions .github/workflows/security-spring.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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' }}
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "assignment_submission")
@Table(name = "assignment_submission", uniqueConstraints = {
@UniqueConstraint(name = "uk_submission_submitter_plan", columnNames = {"user_id", "plan_id"})
})
@SuperBuilder
@SQLDelete(sql = "UPDATE assignment_submission SET deleted_at = NOW() WHERE submission_id = ?")
@SQLRestriction("deleted_at is null")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,19 @@ public interface SubmissionRepository extends JpaRepository<AssignmentSubmission

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 내에 enum 타입은 전체 경로를 다 적어줘야 함 이슈
@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);

}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import com.mjsec.lms.user.domain.type.UserRole;
import com.mjsec.lms.common.util.ValidationUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand Down Expand Up @@ -70,7 +71,12 @@ public SubmissionResponse submitAssignment(Long groupId, Long planId, Long curre
.submitterIp(ipAddress)
.build();

submissionRepository.save(assignmentSubmission);
try {
submissionRepository.save(assignmentSubmission);
} catch (DataIntegrityViolationException e) {
log.warn("Duplicate submission blocked by DB constraint for user: {}, plan: {}", user.getUserId(), planId);
throw new RestApiException(ErrorCode.DUPLICATE_SUBMISSION);
}
log.info("Assignment submission saved successfully: {}", assignmentSubmission);

return createSubmissionResponse(assignmentSubmission);
Expand Down Expand Up @@ -287,13 +293,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()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
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.domain.type.SubmissionStatus;
import com.mjsec.lms.assignment.dto.SubmissionDto;
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.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.dao.DataIntegrityViolationException;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
@DisplayName("과제 중복 제출 방지 테스트")
class AssignmentSubmissionDuplicateTest {

@Mock
private ValidationUtils validationUtils;
@Mock
private SubmissionRepository submissionRepository;
@Mock
private GroupMemberRepository groupMemberRepository;

@InjectMocks
private AssignmentSubmissionService assignmentSubmissionService;

private User mockUser;
private Plan mockPlan;
private SubmissionDto mockDto;

@BeforeEach
void setUp() {
mockUser = User.builder()
.userId(1L)
.name("테스트멘티")
.email("test@test.com")
.studentNumber(20230001L)
.password("password")
.phoneNumber("010-0000-0000")
.build();

mockPlan = Plan.builder()
.planId(10L)
.title("1주차 과제")
.content("블로그에 정리하세요")
.hasAssignment(true)
.build();

mockDto = SubmissionDto.builder()
.content("https://velog.io/@test/week1")
.build();
}

@Nested
@DisplayName("앱 레벨 중복 체크 (validateDuplicateSubmission)")
class AppLevelDuplicateCheck {

@Test
@DisplayName("처음 제출이면 정상적으로 저장된다")
void first_submission_should_succeed() {
// given
when(validationUtils.validateMenteeAccess(anyLong(), anyLong())).thenReturn(mockUser);
when(validationUtils.validatePlan(anyLong())).thenReturn(mockPlan);
when(submissionRepository.save(any(AssignmentSubmission.class)))
.thenAnswer(invocation -> invocation.getArgument(0));

// when
assignmentSubmissionService.submitAssignment(1L, 10L, 20230001L, mockDto, "127.0.0.1");

// then
verify(submissionRepository, times(1)).save(any(AssignmentSubmission.class));
}

@Test
@DisplayName("이미 제출했으면 앱 레벨에서 DUPLICATE_SUBMISSION 예외가 발생한다")
void duplicate_submission_should_throw_at_app_level() {
// given
when(validationUtils.validateMenteeAccess(anyLong(), anyLong())).thenReturn(mockUser);
when(validationUtils.validatePlan(anyLong())).thenReturn(mockPlan);
doThrow(new RestApiException(ErrorCode.DUPLICATE_SUBMISSION))
.when(validationUtils).validateDuplicateSubmission(anyLong(), anyLong());

// when & then
assertThatThrownBy(() ->
assignmentSubmissionService.submitAssignment(1L, 10L, 20230001L, mockDto, "127.0.0.1"))
.isInstanceOf(RestApiException.class)
.satisfies(e -> assertThat(((RestApiException) e).getErrorCode())
.isEqualTo(ErrorCode.DUPLICATE_SUBMISSION));

// save가 호출되지 않아야 함
verify(submissionRepository, never()).save(any());
}
}

@Nested
@DisplayName("DB 레벨 중복 차단 (UniqueConstraint)")
class DbLevelDuplicateCheck {

@Test
@DisplayName("앱 체크를 통과해도 DB unique constraint 위반 시 DUPLICATE_SUBMISSION 예외로 변환된다")
void db_constraint_violation_should_be_converted_to_duplicate_submission() {
// given - 앱 레벨 체크는 통과 (Race Condition 시뮬레이션)
when(validationUtils.validateMenteeAccess(anyLong(), anyLong())).thenReturn(mockUser);
when(validationUtils.validatePlan(anyLong())).thenReturn(mockPlan);
// DB unique constraint 위반 시뮬레이션
when(submissionRepository.save(any(AssignmentSubmission.class)))
.thenThrow(new DataIntegrityViolationException("Duplicate entry for key 'uk_submission_submitter_plan'"));

// when & then
assertThatThrownBy(() ->
assignmentSubmissionService.submitAssignment(1L, 10L, 20230001L, mockDto, "127.0.0.1"))
.isInstanceOf(RestApiException.class)
.satisfies(e -> assertThat(((RestApiException) e).getErrorCode())
.isEqualTo(ErrorCode.DUPLICATE_SUBMISSION));
}

@Test
@DisplayName("DB constraint가 없었다면 Race Condition으로 중복이 저장될 수 있다 (취약점 재현)")
void without_db_constraint_race_condition_allows_duplicates() throws InterruptedException {
// given - 모든 요청이 앱 레벨 체크를 통과 (동시 요청 시뮬레이션)
when(validationUtils.validateMenteeAccess(anyLong(), anyLong())).thenReturn(mockUser);
when(validationUtils.validatePlan(anyLong())).thenReturn(mockPlan);
when(submissionRepository.save(any(AssignmentSubmission.class)))
.thenAnswer(invocation -> invocation.getArgument(0)); // DB constraint 없다고 가정

int threadCount = 10;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(threadCount);
AtomicInteger successCount = new AtomicInteger(0);

for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
startLatch.await(); // 모든 스레드가 동시에 시작
assignmentSubmissionService.submitAssignment(1L, 10L, 20230001L, mockDto, "127.0.0.1");
successCount.incrementAndGet();
} catch (RestApiException e) {
// 중복 차단된 경우
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
doneLatch.countDown();
}
});
}

startLatch.countDown(); // 동시에 출발
doneLatch.await();
executor.shutdown();

// DB constraint 없으면 여러 개가 저장될 수 있음 (취약점)
int savedCount = successCount.get();
System.out.println("DB constraint 없이 동시 " + threadCount + "개 요청 → " + savedCount + "개 저장됨");
assertThat(savedCount).isGreaterThan(0); // 적어도 하나는 성공
}

@Test
@DisplayName("DB constraint가 있으면 동시 요청 중 첫 번째만 성공하고 나머지는 DUPLICATE_SUBMISSION이 된다")
void with_db_constraint_only_first_request_succeeds() throws InterruptedException {
// given
when(validationUtils.validateMenteeAccess(anyLong(), anyLong())).thenReturn(mockUser);
when(validationUtils.validatePlan(anyLong())).thenReturn(mockPlan);

AtomicInteger saveCallCount = new AtomicInteger(0);
// 첫 번째 save만 성공, 나머지는 DB constraint 위반
when(submissionRepository.save(any(AssignmentSubmission.class)))
.thenAnswer(invocation -> {
if (saveCallCount.incrementAndGet() == 1) {
return invocation.getArgument(0); // 첫 번째만 성공
}
throw new DataIntegrityViolationException("Duplicate entry");
});

int threadCount = 10;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(threadCount);
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger duplicateCount = new AtomicInteger(0);

for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
startLatch.await();
assignmentSubmissionService.submitAssignment(1L, 10L, 20230001L, mockDto, "127.0.0.1");
successCount.incrementAndGet();
} catch (RestApiException e) {
if (e.getErrorCode() == ErrorCode.DUPLICATE_SUBMISSION) {
duplicateCount.incrementAndGet();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
doneLatch.countDown();
}
});
}

startLatch.countDown();
doneLatch.await();
executor.shutdown();

// then - 1개만 성공, 나머지는 중복 차단
assertThat(successCount.get()).isEqualTo(1);
assertThat(duplicateCount.get()).isEqualTo(threadCount - 1);
assertThat(successCount.get() + duplicateCount.get()).isEqualTo(threadCount);
}
}

@Nested
@DisplayName("제출된 과제는 정상 데이터를 담고 있어야 한다")
class SubmissionDataIntegrity {

@Test
@DisplayName("제출 시 IP 주소, 상태(SUBMITTED), 제출자 정보가 저장된다")
void submission_should_save_correct_data() {
// given
when(validationUtils.validateMenteeAccess(anyLong(), anyLong())).thenReturn(mockUser);
when(validationUtils.validatePlan(anyLong())).thenReturn(mockPlan);

List<AssignmentSubmission> savedSubmissions = new ArrayList<>();
when(submissionRepository.save(any(AssignmentSubmission.class)))
.thenAnswer(invocation -> {
AssignmentSubmission saved = invocation.getArgument(0);
savedSubmissions.add(saved);
return saved;
});

// when
assignmentSubmissionService.submitAssignment(1L, 10L, 20230001L, mockDto, "192.168.1.1");

// then
assertThat(savedSubmissions).hasSize(1);
AssignmentSubmission saved = savedSubmissions.get(0);
assertThat(saved.getContent()).isEqualTo("https://velog.io/@test/week1");
assertThat(saved.getSubmitterIp()).isEqualTo("192.168.1.1");
assertThat(saved.getStatus()).isEqualTo(SubmissionStatus.SUBMITTED);
assertThat(saved.getSubmitter()).isEqualTo(mockUser);
assertThat(saved.getPlan()).isEqualTo(mockPlan);
}
}
}
Loading