diff --git a/backend/src/test/java/dev/awesomedev/mirim/web/SecurityContractTest.java b/backend/src/test/java/dev/awesomedev/mirim/web/SecurityContractTest.java new file mode 100644 index 0000000..a2d895c --- /dev/null +++ b/backend/src/test/java/dev/awesomedev/mirim/web/SecurityContractTest.java @@ -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); + } +}