File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
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());
}
}