test(SEC-01): 보안·권한 계약 통합테스트 8개 (@WebMvcTest)
All checks were successful
CI / backend-test (push) Successful in 50s
CI / frontend-build (push) Successful in 17s

SecurityConfig의 URL→권한 규칙을 실제로 검증 — 규칙이 깨지면 CI에서 빨간불:
- /api/mentor/**: 비로그인→401, STUDENT→403, MENTOR→200
- /api/**(코딩): 비로그인→401, 로그인→200
- /api/auth/login·signup·find-id: permitAll(잘못된 본문 400으로 도달 증명)
- 인증은 실제 앱과 동일하게 세션에 SecurityContext를 넣어 흉내(가짜 우회 아님)
- @WebMvcTest 슬라이스 + @MockitoBean — DB 없이 웹계층+보안필터만 로드
- spring-security-test 불필요(세션 직접 주입이 더 충실) → 의존성 추가 안 함

전체 37개 테스트 통과(백엔드 29→37). 권한 규칙이 이제 자동 회귀 방지된다.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AWESOMEDEV 2026-07-17 17:33:37 +09:00
parent 7788509651
commit edafae9f86

View File

@ -0,0 +1,158 @@
package dev.awesomedev.mirim.web;
import dev.awesomedev.mirim.config.SecurityConfig;
import dev.awesomedev.mirim.domain.User;
import dev.awesomedev.mirim.repository.UserRepository;
import dev.awesomedev.mirim.service.AuthService;
import dev.awesomedev.mirim.service.CodingService;
import dev.awesomedev.mirim.service.CourseProgressService;
import dev.awesomedev.mirim.service.LoginAttemptService;
import dev.awesomedev.mirim.service.ProgressService;
import dev.awesomedev.mirim.service.QuizService;
import dev.awesomedev.mirim.service.StudentProfileService;
import dev.awesomedev.mirim.service.StudentPurgeService;
import dev.awesomedev.mirim.service.SubmissionService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 파일이 하는 :
* "어떤 URL에 어떤 권한이 필요한가"라는 보안 계약(SecurityConfig) 실제로 검증하는 통합테스트다.
*
* 학습 포인트 테스트가 값어치 있나?
* 권한 규칙 전체가 SecurityConfig.java (URL 패턴 권한)에만 있고, 컨트롤러엔
* 권한 검사 코드가 없다(전적으로 URL 패턴에 의존). 누군가 SecurityConfig를 리팩터링하다
* /api/mentor/** 규칙을 실수로 깨면, 학생이 전체 개인정보 CSV를 있는 사고가 난다.
* 테스트가 계약을 박아, 규칙이 깨지면 CI에서 빨간불이 뜨게 한다.
*
* 학습 포인트 @WebMvcTest 슬라이스 테스트.
* DB·전체 앱을 띄우지 않고 "웹 계층(컨트롤러 + 보안 필터)" 얇게 로드한다. 서비스는
* @MockitoBean으로 가짜를 끼워, 우리가 검증하려는 "보안 규칙"에만 집중한다.
*
* 학습 포인트 인증 상태를 어떻게 흉내 내나?
* 실제 앱과 똑같이 "세션에 SecurityContext를 넣는다"(AuthController가 로그인 성공 하는 ).
* 세션을 요청에 실어 보내면, 보안 필터가 세션에서 권한을 복원해 규칙을 평가한다.
* 가짜 우회가 아니라 실제 흐름을 그대로 태운 검증이다.
*/
@WebMvcTest(controllers = {MentorController.class, CodingController.class, AuthController.class})
@Import(SecurityConfig.class)
class SecurityContractTest {
@Autowired
private MockMvc mockMvc;
// 컨트롤러들이 의존하는 서비스는 전부 가짜로 끼운다(보안 규칙만 보려는 것이므로).
@MockitoBean private AuthService authService;
@MockitoBean private LoginAttemptService loginAttemptService;
@MockitoBean private CodingService codingService;
@MockitoBean private SubmissionService submissionService;
@MockitoBean private CourseProgressService courseProgressService;
@MockitoBean private ProgressService progressService;
@MockitoBean private QuizService quizService;
@MockitoBean private StudentProfileService studentProfileService;
@MockitoBean private StudentPurgeService studentPurgeService;
@MockitoBean private UserRepository userRepository;
/** 실제 로그인과 똑같이, 주어진 역할의 인증 정보를 담은 세션을 만든다. */
private MockHttpSession sessionWithRole(String username, String role) {
var auth = UsernamePasswordAuthenticationToken.authenticated(
username, null, List.of(new SimpleGrantedAuthority("ROLE_" + role)));
SecurityContext ctx = SecurityContextHolder.createEmptyContext();
ctx.setAuthentication(auth);
MockHttpSession session = new MockHttpSession();
session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, ctx);
return session;
}
// /api/mentor/** = 멘토 전용
@Test
@DisplayName("멘토 API: 비로그인이면 401")
void mentor_requires_login() throws Exception {
mockMvc.perform(get("/api/mentor/students"))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("멘토 API: 학생(STUDENT)이면 403 — 권한 부족")
void mentor_forbidden_for_student() throws Exception {
mockMvc.perform(get("/api/mentor/students").session(sessionWithRole("s1", "STUDENT")))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("멘토 API: 멘토(MENTOR)면 통과한다(200)")
void mentor_ok_for_mentor() throws Exception {
when(studentProfileService.allProfiles()).thenReturn(List.of());
mockMvc.perform(get("/api/mentor/students").session(sessionWithRole("m1", "MENTOR")))
.andExpect(status().isOk());
}
// /api/** = 로그인 필요
@Test
@DisplayName("일반 API(코딩 문제): 비로그인이면 401")
void coding_requires_login() throws Exception {
mockMvc.perform(get("/api/coding/problems"))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("일반 API(코딩 문제): 로그인한 학생이면 통과한다(200)")
void coding_ok_for_authenticated() throws Exception {
when(authService.requireUser(any())).thenReturn(new User("s1", "해시", "학생", "STUDENT", "DEV"));
when(codingService.mySubmissionsByProblemId(any())).thenReturn(Map.of());
when(codingService.allProblems()).thenReturn(List.of());
mockMvc.perform(get("/api/coding/problems").session(sessionWithRole("s1", "STUDENT")))
.andExpect(status().isOk());
}
// /api/auth/** = 무인증 허용(permitAll)
// 학습 포인트: 잘못된 본문을 보내 400이 나오면, "인증에 막히지 않고 컨트롤러까지 도달했다"
// 증거다(막혔다면 401이 났을 것이다). permitAll이 살아 있음을 400으로 증명한다.
@Test
@DisplayName("로그인 API: 비로그인도 도달 가능(permitAll) — 잘못된 본문은 400")
void login_is_public() throws Exception {
mockMvc.perform(post("/api/auth/login").contentType(MediaType.APPLICATION_JSON).content("{}"))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("회원가입 API: 비로그인도 도달 가능(permitAll) — 잘못된 본문은 400")
void signup_is_public() throws Exception {
mockMvc.perform(post("/api/auth/signup").contentType(MediaType.APPLICATION_JSON).content("{}"))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("아이디 찾기 API: 비로그인도 도달 가능(permitAll) — 401이 아니다")
void findId_is_public() throws Exception {
int statusCode = mockMvc.perform(
post("/api/auth/find-id").contentType(MediaType.APPLICATION_JSON).content("{}"))
.andReturn().getResponse().getStatus();
// permitAll이므로 인증 차단(401) 아니어야 한다. (검증 실패로 400이 나든, 200이 나든 무방)
org.junit.jupiter.api.Assertions.assertNotEquals(401, statusCode);
}
}