package kr.itn.itnhub.config; import jakarta.servlet.http.Cookie; import kr.itn.itnhub.AbstractDbTest; import org.junit.jupiter.api.MethodOrderer; import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestMethodOrder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; 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; /** * 실행 순서를 명시적으로 고정한다({@code @TestMethodOrder}). 이유: Spring Security * Test의 {@code with(csrf())}는 최초 호출 시 리플렉션으로 캐시된 애플리케이션 컨텍스트의 * 실제 {@code CsrfFilter} 빈의 {@code tokenRepository} 필드를 세션 기반 테스트 저장소로 * "영구히" 바꿔치기한다({@code WebTestUtils.setCsrfTokenRepository} 참고). 그 뒤로는 같은 * 컨텍스트를 공유하는 이 클래스의 모든 테스트에서 실제 {@code CookieCsrfTokenRepository}가 * 더 이상 동작하지 않는다. 그래서 실제 쿠키 왕복을 검증하는 테스트는 {@code with(csrf())}를 * 쓰는 테스트보다 반드시 먼저 실행돼야 한다. */ @AutoConfigureMockMvc @TestMethodOrder(MethodOrderer.OrderAnnotation.class) class SecurityConfigTest extends AbstractDbTest { @Autowired MockMvc mvc; /** * Finding 1 회귀 테스트. {@code .with(csrf())}를 전혀 쓰지 않는다. * 브라우저가 실제로 겪는 흐름 그대로: 먼저 permitAll 경로에 평범한 GET을 보내 * 응답의 Set-Cookie로 XSRF-TOKEN을 받고, 그 쿠키 값을 X-XSRF-TOKEN 헤더로 * 되돌려 보내 로그인한다. CookieCsrfTokenRepository가 실제로 쿠키를 쓰지 않으면 * (지연 토큰이 resolve되지 않으면) xsrfCookie가 null이 되어 이 테스트는 실패한다. */ @Test @Order(1) void CSRF_쿠키를_받아서_로그인에_사용할_수_있다() throws Exception { MvcResult csrfResult = mvc.perform(get("/")).andReturn(); Cookie xsrfCookie = csrfResult.getResponse().getCookie("XSRF-TOKEN"); assertThat(xsrfCookie).as("XSRF-TOKEN 쿠키가 Set-Cookie로 내려와야 한다").isNotNull(); mvc.perform(post("/api/auth/login") .param("username", "admin") .param("password", "test-password") .cookie(xsrfCookie) .header("X-XSRF-TOKEN", xsrfCookie.getValue())) .andExpect(status().isOk()); } /** * CSRF 토큰을 전혀 보내지 않으면 CsrfFilter 자신의 AccessDeniedHandler가 * 처리한다(이 프로젝트가 exceptionHandling에 설정한 401 엔트리포인트를 타지 않는다). * 실측 결과 403 Forbidden이었다. */ @Test @Order(2) void CSRF_토큰_없이_로그인하면_거부된다() throws Exception { mvc.perform(post("/api/auth/login") .param("username", "admin") .param("password", "test-password")) .andExpect(status().isForbidden()); } /** * Finding 4. formLogin()에 커스텀 loginPage를 지정하면 * DefaultLoginPageGeneratingFilter가 등록되지 않아야 한다. */ @Test @Order(3) void 로그인_페이지는_스프링_기본_로그인폼을_반환하지_않는다() throws Exception { MvcResult result = mvc.perform(get("/login")).andReturn(); String body = result.getResponse().getContentAsString(); assertThat(body).doesNotContain("Please sign in"); } @Test @Order(4) void 인증없이_api를_부르면_401이다() throws Exception { mvc.perform(get("/api/orgs")) .andExpect(status().isUnauthorized()); } /** * Finding 2. 예전 테스트는 틀린 자격증명으로 로그인해 401을 확인하는 것만으로 * "인증 없이 접근 가능"을 증명하려 했지만, permitAll()이 삭제돼 /api/** → * authenticated()로 떨어져도 같은 401이 나와 아무것도 구분하지 못했다. * 실제로는 두 401이 서로 다른 필터에서 나온다: * - 로그인 실패: UsernamePasswordAuthenticationFilter가 바로 failureHandler를 * 호출하고 끝난다. ExceptionTranslationFilter/AuthorizationFilter를 거치지 * 않으므로 세션이 생기지 않는다. * - 인가 차단: ExceptionTranslationFilter.sendStartAuthentication()이 엔트리 * 포인트를 부르기 전에 기본 RequestCache(HttpSessionRequestCache)로 원 요청을 * 세션에 저장한다. 즉 세션이 생긴다. * 이 세션 생성 여부 차이로 두 경로가 실제로 다르다는 것을, 즉 permitAll()이 * 로그인 엔드포인트에서 실제로 작동하고 있다는 것을 증명한다. */ @Test @Order(5) void 로그인_엔드포인트는_인증없이_접근할_수_있다() throws Exception { MvcResult loginResult = mvc.perform(post("/api/auth/login") .param("username", "nobody") .param("password", "nothing") .with(csrf())) .andExpect(status().isUnauthorized()) // 403이 아니라 401이어야 한다 .andReturn(); assertThat(loginResult.getRequest().getSession(false)) .as("인증 실패는 AuthorizationFilter를 거치지 않으므로 세션이 생기면 안 된다") .isNull(); MvcResult blockedResult = mvc.perform(get("/api/orgs")) .andExpect(status().isUnauthorized()) .andReturn(); assertThat(blockedResult.getRequest().getSession(false)) .as("인가 차단은 ExceptionTranslationFilter가 원 요청을 세션에 저장한다") .isNotNull(); } @Test @Order(6) void 올바른_비밀번호로_로그인하면_200이다() throws Exception { mvc.perform(post("/api/auth/login") .param("username", "admin") .param("password", "test-password") .with(csrf())) .andExpect(status().isOk()); } @Test @Order(7) void 틀린_비밀번호로_로그인하면_401이다() throws Exception { mvc.perform(post("/api/auth/login") .param("username", "admin") .param("password", "wrong") .with(csrf())) .andExpect(status().isUnauthorized()); } }