스프링시큐리티 테스트
- SpringSecurity를 사용하는 프로젝트의 테스트는 SpringSecurity가 없는 프로젝트의 테스트와 조금 다른 부분이 있다.
- SpringSecurity의 테스트에서는 User가 로그인한 상태를 가정하고 테스트해야 하는 경우가 많다.
- 인증을 받지 않은 상태로 테스트를 하면 SpringSecurity에서 요청 자체를 막기 때문에 테스트가 제대로 동작조차 하지 못한다.
- 이런 문제는 프로젝트에 spring-security-test를 사용해서 해결할 수 있다.
설정
- 의존성 추가
- testImplementation 'org.springframework.security:spring-security-test'
- Test 실행 전 MockMvc에 springSecurity (static 메소드)를 설정한다.
@BeforeEach
public void setUp(@Autowired WebApplicationContext applicationContext) {
this.mockMvc = MockMvcBuilders.webAppContextSetup(applicationContext)
.apply(springSecurity())
.build();
}
@WithMockUser
- Mock(가짜) User를 생성하고 Authentication을 만드는 애노테이션이다.
- 여기서 User는 org.springframework.security.core.userdetails.User를 말한다.
- 내부에서 UserDetails를 직접 구현해서 Custom User를 만들어 사용하는 경우에는 WithMockUser를 사용하면 문제가 발생할 수 있다.
- WithMockUser는 org.springframework.security.core.userdetails.User를 만들어 주지만 우리가 필요한 User는 Custom User이기 때문이다.
- 코드예시
@Test
@WithMockUser
void getNotice_인증있음() throws Exception {
mockMvc.perform(get("/notice"))
.andExpect(status().isOk())
.andExpect(view().name("notice/index"));
}
@Test
@WithMockUser(roles = {"USER"}, username = "admin", password = "admin")
void postNotice_유저인증있음() throws Exception {
mockMvc.perform(
post("/notice").with(csrf())
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("title", "제목")
.param("content", "내용")
).andExpect(status().isForbidden()); // 접근 거부
}
@Test
@WithMockUser(roles = {"ADMIN"}, username = "admin", password = "admin")
void postNotice_어드민인증있음() throws Exception {
mockMvc.perform(
post("/notice").with(csrf())
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("title", "제목")
.param("content", "내용")
).andExpect(redirectedUrl("notice")).andExpect(status().is3xxRedirection());
}
@Test
@WithMockUser(roles = {"USER"}, username = "admin", password = "admin")
void deleteNotice_유저인증있음() throws Exception {
Notice notice = noticeRepository.save(new Notice("제목", "내용"));
mockMvc.perform(
delete("/notice?id=" + notice.getId()).with(csrf())
).andExpect(status().isForbidden()); // 접근 거부
}
@Test
@WithMockUser(roles = {"ADMIN"}, username = "admin", password = "admin")
void deleteNotice_어드민인증있음() throws Exception {
Notice notice = noticeRepository.save(new Notice("제목", "내용"));
mockMvc.perform(
delete("/notice?id=" + notice.getId()).with(csrf())
).andExpect(redirectedUrl("notice")).andExpect(status().is3xxRedirection());
}
@WithUserDetails
- WithMockUser와 마찬가지로 Mock(가짜) User를 생성하고 Authentication을 만듭니다.
- WithMockUser와 다른점은 가짜 User를 가져올 때, UserDetailsService의 Bean 이름을 넣어줘서 userDetailsService.loadUserByUsername(String username)을 통해 User를 가져온다는 점이다.
- 코드예시
@BeforeEach
public void setUp(@Autowired WebApplicationContext applicationContext) {
this.mockMvc = MockMvcBuilders.webAppContextSetup(applicationContext)
.apply(springSecurity())
.alwaysDo(print())
.build();
user = userRepository.save(new User("user123", "user", "ROLE_USER"));
admin = userRepository.save(new User("admin123", "admin", "ROLE_ADMIN"));
}
- @WithUserDetails 설정한 데이터를 테스트 전에 위와 같이 만들어 준다.
@Test
@WithUserDetails(
value = "user123", // userDetailsService를 통해 가져올 수 있는 유저
userDetailsServiceBeanName = "userDetailsService", // UserDetailsService 구현체의 Bean
setupBefore = TestExecutionEvent.TEST_EXECUTION // 테스트 실행 직전에 유저를 가져온다.
)
void getNote_인증있음() throws Exception {
mockMvc.perform(
get("/note")
).andExpect(status().isOk())
.andExpect(view().name("note/index"))
.andDo(print());
}
@Test
@WithUserDetails(
value = "admin123",
userDetailsServiceBeanName = "userDetailsService",
setupBefore = TestExecutionEvent.TEST_EXECUTION
)
void postNote_어드민인증있음() throws Exception {
mockMvc.perform(
post("/note").with(csrf())
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("title", "제목")
.param("content", "내용")
).andExpect(status().isForbidden()); // 접근 거부
}
@Test
@WithUserDetails(
value = "user123",
userDetailsServiceBeanName = "userDetailsService",
setupBefore = TestExecutionEvent.TEST_EXECUTION
)
void postNote_유저인증있음() throws Exception {
mockMvc.perform(
post("/note").with(csrf())
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("title", "제목")
.param("content", "내용")
).andExpect(redirectedUrl("note")).andExpect(status().is3xxRedirection());
}
@Test
@WithUserDetails(
value = "user123",
userDetailsServiceBeanName = "userDetailsService",
setupBefore = TestExecutionEvent.TEST_EXECUTION
)
void deleteNote_유저인증있음() throws Exception {
Note note = noteRepository.save(new Note("제목", "내용", user));
mockMvc.perform(
delete("/note?id=" + note.getId()).with(csrf())
).andExpect(redirectedUrl("note")).andExpect(status().is3xxRedirection());
}
@Test
@WithUserDetails(
value = "admin123",
userDetailsServiceBeanName = "userDetailsService",
setupBefore = TestExecutionEvent.TEST_EXECUTION
)
void deleteNote_어드민인증있음() throws Exception {
Note note = noteRepository.save(new Note("제목", "내용", user));
mockMvc.perform(
delete("/note?id=" + note.getId()).with(csrf()).with(user(admin))
).andExpect(status().isForbidden()); // 접근 거부
}
- userDetailsServiceBeanName = "userDetailsService"
- Config 에 등록한 userDetailsService 를 넣어준다.
- setupBefore = TestExecutionEvent.TEST_EXECUTION
@WithAnonymousUser
- WithMockUser와 동일하지만 인증된 유저 대신에 익명(Anonymous)유저를 Authentication에서 사용한다.
- 익명이기 때문에 멤버변수에 유저와 관련된 값이 없다.
- 코드예시
@Test
@WithAnonymousUser
public void index_anonymous() throws Exception {
mockMvc.perform(get(INDEX_PAGE))
.andDo(print())
.andExpect(status()
.isOk());
}
@WithSecurityContext
- 다른 방식들은 Authentication을 가짜로 만들었다고 한다면 WithSecurityContext는 아예 SecurityContext를 만든다.
- WithSecurityContextFactory를 Implement한 Class를 factory 에 넣어주면 된다.
- 코드 예시
public class WithMockCustomUserSecurityContextFactory implements WithSecurityContextFactory<WithMockCustomUser> {
@Override
public SecurityContext createSecurityContext(WithMockCustomUser annotation) {
final SecurityContext context = SecurityContextHolder.createEmptyContext();
final Authentication auth = new JwtAuthenticationToken(new JwtAuthentication(1L, "tester", new Email("test00@gmail.com")),
createAuthorityList(Role.USER.value()));
context.setAuthentication(auth);
return context;
}
}
- 위와 같이 WithSecurityContextFactory 를 구현한 클래스를 구현해 준다.
- Authentication 객체를 구현에 맞게 정의한다.
- 생성된 Authentication 를 바탕으로 SecurityContext 를 생성한다.
- 그 다음으로 아래와 같이 커스텀 애노테이션을 만들어 준다.
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class)
public @interface WithMockCustomUser {
String name() default "name";
String password() default "password";
}
- 위에서 설정한 대로 만들어진 SecurityContext를 아래와 같이 사용할 수 있다.
@Test
@WithCustomUser(first = "name123", second = "password123")
void WithCustomUserTest() {
...
}
with(user( ))
- 다른 방식은 어노테이션 기반인 반면에 이 방식은 직접 User를 MockMvc에 주입하는 방법이다.
- WithMockUser와 마찬가지로 유저를 생성해서 Principal에 넣고 Authentication을 생성한다.
- mockMvc.perform(get("/admin").with(user(user)))
- org.springframework.security.test.web.servlet.request.user를 사용한다.
커스텀 애노테이션 적용
- 어노테이션 value가 중복되어 사용성이 다소 떨어지는 문제가 발생할 수 있다.
- 이럴 경우 간단히 메타 어노테이션을 활용하여 추가적인 입력을 줄일 수 있다.
- 코드예시
@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(username = "user", roles="USER")
public @interface WithNormalUser {
}
@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(username = "admin", roles="ADMIN")
public @interface WithAdminUser {
}
- 위와 같이 커스텀 에노테이션 생성한다.
- User 권한을 가진 유저인 경우
@Test
@WithNormalUser
public void index_user() throws Exception {
mockMvc.perform(get(INDEX_PAGE))
.andDo(print())
.andExpect(status().isOk());
}
@Test
@WithAdminUser
public void admin_admin() throws Exception {
mockMvc.perform(get(ADMIN_PAGE))
.andDo(print())
.andExpect(status().isOk());
}
REFERENCES