diff --git a/server/src/intTest/java/com/ticketing/server/global/config/WithAuthUser.java b/server/src/intTest/java/com/ticketing/server/global/config/WithAuthUser.java new file mode 100644 index 0000000..2e86aad --- /dev/null +++ b/server/src/intTest/java/com/ticketing/server/global/config/WithAuthUser.java @@ -0,0 +1,15 @@ +package com.ticketing.server.global.config; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import org.springframework.security.test.context.support.WithSecurityContext; + +@Retention(RetentionPolicy.RUNTIME) +@WithSecurityContext(factory = WithAuthUserSecurityContextFactory.class) +public @interface WithAuthUser { + + String email(); + + String role(); + +} diff --git a/server/src/intTest/java/com/ticketing/server/global/config/WithAuthUserSecurityContextFactory.java b/server/src/intTest/java/com/ticketing/server/global/config/WithAuthUserSecurityContextFactory.java new file mode 100644 index 0000000..c38664e --- /dev/null +++ b/server/src/intTest/java/com/ticketing/server/global/config/WithAuthUserSecurityContextFactory.java @@ -0,0 +1,27 @@ +package com.ticketing.server.global.config; + +import java.util.List; +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.core.userdetails.User; +import org.springframework.security.test.context.support.WithSecurityContextFactory; + +public class WithAuthUserSecurityContextFactory implements WithSecurityContextFactory { + + @Override + public SecurityContext createSecurityContext(WithAuthUser annotation) { + String email = annotation.email(); + String role = annotation.role(); + List authorities = List.of(new SimpleGrantedAuthority(role)); + + User authUser = new User(email, "", authorities); + UsernamePasswordAuthenticationToken token = + new UsernamePasswordAuthenticationToken(authUser, "", authorities); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(token); + return context; + } + +} diff --git a/server/src/intTest/java/com/ticketing/server/user/application/UserControllerTest.java b/server/src/intTest/java/com/ticketing/server/user/application/UserControllerTest.java index c0c2b6c..f7c82f3 100644 --- a/server/src/intTest/java/com/ticketing/server/user/application/UserControllerTest.java +++ b/server/src/intTest/java/com/ticketing/server/user/application/UserControllerTest.java @@ -1,7 +1,242 @@ package com.ticketing.server.user.application; -import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +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.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ticketing.server.global.config.WithAuthUser; +import com.ticketing.server.user.application.request.LoginRequest; +import com.ticketing.server.user.application.request.SignUpRequest; +import com.ticketing.server.user.application.request.UserChangeGradeRequest; +import com.ticketing.server.user.application.request.UserChangePasswordRequest; +import com.ticketing.server.user.application.request.UserDeleteRequest; +import com.ticketing.server.user.domain.UserGrade; +import com.ticketing.server.user.domain.UserGrade.ROLES; +import com.ticketing.server.user.domain.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@Transactional class UserControllerTest { + private static final String LOGIN_URL = "/api/auth/token"; + + private static final String BASICS_URL = "/api/users"; + private static final String DETAILS_URL = "/api/users/details"; + private static final String CHANGE_PASSWORD_URL = "/api/users/password"; + private static final String CHANGE_GRADE_URL = "/api/users/grade"; + + private static final String NAME = "$.name"; + private static final String EMAIL = "$.email"; + private static final String GRADE = "$.grade"; + private static final String PHONE = "$.phone"; + private static final String BEFORE_GRADE = "$.beforeGrade"; + private static final String AFTER_GRADE = "$.afterGrade"; + + private static final String USER_EMAIL = "testemail@ticketing.com"; + private static final String USER_PW = "qwe123"; + private static final String USER_NAME = "김철수"; + private static final String USER_PHONE = "010-1234-5678"; + + @Autowired + UserRepository userRepository; + + @Autowired + ObjectMapper mapper; + + @Autowired + WebApplicationContext context; + + MockMvc mvc; + + SignUpRequest signUpRequest; + + + @Test + @DisplayName("회원가입 성공") + void registerSuccess() throws Exception { + // given + // when + ResultActions resultActions = mvc.perform( + post(BASICS_URL) + .content(mapper.writeValueAsString(signUpRequest)) + .contentType(APPLICATION_JSON) + ); + + // then + resultActions + .andExpect(status().isCreated()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath(NAME).value(USER_NAME)) + .andExpect(jsonPath(EMAIL).value(USER_EMAIL)); + } + + @Test + @DisplayName("유저 정보 조회") + @WithAuthUser(email = USER_EMAIL, role = ROLES.USER) + void detailsSuccess() throws Exception { + // given + mvc.perform( + post(BASICS_URL) + .content(mapper.writeValueAsString(signUpRequest)) + .contentType(APPLICATION_JSON) + ); + + // when + ResultActions resultActions = mvc.perform( + get(DETAILS_URL) + .content(mapper.writeValueAsString(signUpRequest)) + .contentType(APPLICATION_JSON) + ); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath(NAME).value(USER_NAME)) + .andExpect(jsonPath(EMAIL).value(USER_EMAIL)) + .andExpect(jsonPath(GRADE).value(UserGrade.USER.name())) + .andExpect(jsonPath(PHONE).value(USER_PHONE)); + } + + @Test + @DisplayName("유저 탈퇴 성공") + @WithAuthUser(email = USER_EMAIL, role = ROLES.USER) + void deleteUserSuccess() throws Exception { + // given + UserDeleteRequest deleteRequest = new UserDeleteRequest(USER_EMAIL, USER_PW); + LoginRequest loginRequest = new LoginRequest(USER_EMAIL, USER_PW); + mvc.perform( + post(BASICS_URL) + .content(mapper.writeValueAsString(signUpRequest)) + .contentType(APPLICATION_JSON) + ); + + // when + + // 1. 회원 탈퇴 진행 + mvc.perform( + delete(BASICS_URL) + .content(mapper.writeValueAsString(deleteRequest)) + .contentType(APPLICATION_JSON) + ); + + // 2. 탈퇴된 계정 로그인 + ResultActions resultActions = mvc.perform(post(LOGIN_URL) + .content(mapper.writeValueAsString(loginRequest)) + .contentType(MediaType.APPLICATION_JSON)); + + // then + resultActions + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("비밀번호 변경 성공") + @WithAuthUser(email = USER_EMAIL, role = ROLES.USER) + void changePasswordSuccess() throws Exception { + // given + UserChangePasswordRequest changePasswordRequest = new UserChangePasswordRequest(USER_PW, "qwe1234"); + LoginRequest loginRequest = new LoginRequest(USER_EMAIL, USER_PW); + mvc.perform( + post(BASICS_URL) + .content(mapper.writeValueAsString(this.signUpRequest)) + .contentType(APPLICATION_JSON) + ); + + // when + + // 1. 패스워드 변경 + mvc.perform( + put(CHANGE_PASSWORD_URL) + .content(mapper.writeValueAsString(changePasswordRequest)) + .contentType(APPLICATION_JSON) + ) + .andExpect(status().isOk()); + + // 2. 변경 전 계정으로 로그인 + ResultActions resultActions = mvc.perform(post(LOGIN_URL) + .content(mapper.writeValueAsString(loginRequest)) + .contentType(MediaType.APPLICATION_JSON)); + + // then + resultActions + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("유저 등급 변경") + @WithAuthUser(email = "admin@ticketing.com", role = ROLES.ADMIN) + void changeGradeSuccess() throws Exception { + // given + UserChangeGradeRequest changeGradeRequest = new UserChangeGradeRequest(USER_EMAIL, UserGrade.STAFF); + mvc.perform( + post(BASICS_URL) + .content(mapper.writeValueAsString(signUpRequest)) + .contentType(APPLICATION_JSON) + ); + + // when + ResultActions resultActions = mvc.perform( + post(CHANGE_GRADE_URL) + .content(mapper.writeValueAsString(changeGradeRequest)) + .contentType(APPLICATION_JSON) + ); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath(EMAIL).value(USER_EMAIL)) + .andExpect(jsonPath(BEFORE_GRADE).value(UserGrade.USER.name())) + .andExpect(jsonPath(AFTER_GRADE).value(UserGrade.STAFF.name())); + } + + @Test + @DisplayName("유저 등급 변경 실패 - 권한 등급이 낮을 경우") + @WithAuthUser(email = "staff@ticketing.com", role = ROLES.STAFF) + void changeGradeFail() throws Exception { + // given + UserChangeGradeRequest changeGradeRequest = new UserChangeGradeRequest(USER_EMAIL, UserGrade.STAFF); + + // when + ResultActions resultActions = mvc.perform( + post(CHANGE_GRADE_URL) + .content(mapper.writeValueAsString(changeGradeRequest)) + .contentType(APPLICATION_JSON) + ); + + // then + resultActions + .andExpect(status().isForbidden()); + } + + @BeforeEach + void init() { + mvc = MockMvcBuilders + .webAppContextSetup(context) + .apply(springSecurity()) + .build(); + + signUpRequest = new SignUpRequest(USER_NAME, USER_EMAIL, USER_PW, USER_PHONE); + } + } diff --git a/server/src/intTest/resources/application.yml b/server/src/intTest/resources/application.yml index 2a8778c..69fa54d 100644 --- a/server/src/intTest/resources/application.yml +++ b/server/src/intTest/resources/application.yml @@ -20,6 +20,9 @@ spring: pathmatch: matching-strategy: ant_path_matcher + config: + import: "optional:configserver:" + jasypt: encryptor: bean: jasyptStringEncryptor diff --git a/server/src/main/java/com/ticketing/server/global/exception/GlobalExceptionHandler.java b/server/src/main/java/com/ticketing/server/global/exception/GlobalExceptionHandler.java index dcab64d..810d675 100644 --- a/server/src/main/java/com/ticketing/server/global/exception/GlobalExceptionHandler.java +++ b/server/src/main/java/com/ticketing/server/global/exception/GlobalExceptionHandler.java @@ -21,6 +21,7 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.AuthenticationException; import org.springframework.validation.BindException; import org.springframework.validation.ObjectError; @@ -211,6 +212,17 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { return ResponseEntity.status(response.getStatus()).headers(new HttpHeaders()).body(response); } + /** + * 이메일이 존재하지 않을 경우 + */ + @ExceptionHandler(value = BadCredentialsException.class) + protected ResponseEntity handleBadCredentialsException(BadCredentialsException ex) { + log.error("BadCredentialsException :: ", ex); + + ErrorResponse response = new ErrorResponse(UNAUTHORIZED, ex.getLocalizedMessage(), "아이디 혹은 패스워드가 일치하지 않습니다."); + return ResponseEntity.status(response.getStatus()).headers(new HttpHeaders()).body(response); + } + /** * 인증 정보가 없을 때 */ diff --git a/server/src/main/java/com/ticketing/server/user/application/request/UserChangeGradeRequest.java b/server/src/main/java/com/ticketing/server/user/application/request/UserChangeGradeRequest.java index ba70360..3860315 100644 --- a/server/src/main/java/com/ticketing/server/user/application/request/UserChangeGradeRequest.java +++ b/server/src/main/java/com/ticketing/server/user/application/request/UserChangeGradeRequest.java @@ -4,9 +4,13 @@ import com.ticketing.server.user.domain.UserGrade; import javax.validation.constraints.Email; import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter +@NoArgsConstructor +@AllArgsConstructor public class UserChangeGradeRequest { @NotEmpty(message = "{validation.not.empty.email}") diff --git a/server/src/main/java/com/ticketing/server/user/application/response/UserDetailResponse.java b/server/src/main/java/com/ticketing/server/user/application/response/UserDetailResponse.java index 27bb6c0..bc825dc 100644 --- a/server/src/main/java/com/ticketing/server/user/application/response/UserDetailResponse.java +++ b/server/src/main/java/com/ticketing/server/user/application/response/UserDetailResponse.java @@ -1,8 +1,6 @@ package com.ticketing.server.user.application.response; import com.ticketing.server.user.domain.UserGrade; -import com.ticketing.server.user.service.dto.UserDetailDTO; -import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/server/src/main/java/com/ticketing/server/user/service/CustomUserDetailsService.java b/server/src/main/java/com/ticketing/server/user/service/CustomUserDetailsService.java index 3a819c2..c901083 100644 --- a/server/src/main/java/com/ticketing/server/user/service/CustomUserDetailsService.java +++ b/server/src/main/java/com/ticketing/server/user/service/CustomUserDetailsService.java @@ -1,6 +1,5 @@ package com.ticketing.server.user.service; -import com.ticketing.server.global.exception.ErrorCode; import com.ticketing.server.user.domain.User; import com.ticketing.server.user.domain.repository.UserRepository; import java.util.Collections; @@ -21,7 +20,7 @@ public class CustomUserDetailsService implements UserDetailsService { public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { return userRepository.findByEmailAndDeletedAtNull(email) .map(this::createUserDetails) - .orElseThrow(ErrorCode::throwEmailNotFound); + .orElseThrow(() -> new UsernameNotFoundException(email)); } private UserDetails createUserDetails(User user) { diff --git a/server/src/test/java/com/ticketing/server/global/security/jwt/JwtFilterTest.java b/server/src/test/java/com/ticketing/server/global/security/jwt/JwtFilterTest.java new file mode 100644 index 0000000..367b4a4 --- /dev/null +++ b/server/src/test/java/com/ticketing/server/global/security/jwt/JwtFilterTest.java @@ -0,0 +1,120 @@ +package com.ticketing.server.global.security.jwt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.ticketing.server.global.factory.YamlPropertySourceFactory; +import com.ticketing.server.user.domain.UserGrade; +import com.ticketing.server.user.domain.UserGrade.ROLES; +import com.ticketing.server.user.service.dto.TokenDTO; +import java.io.IOException; +import java.util.Collection; +import java.util.Collections; +import javax.servlet.ServletException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.PropertySource; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.User; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +@ExtendWith(SpringExtension.class) +@EnableConfigurationProperties(value = JwtProperties.class) +@PropertySource(value = "classpath:application.yml", factory = YamlPropertySourceFactory.class) +class JwtFilterTest { + + @Autowired + private JwtProperties jwtProperties; + + private MockHttpServletRequest mockRequest; + private MockHttpServletResponse mockResponse; + private MockFilterChain mockFilterChain; + + private JwtFilter jwtFilter; + + @BeforeEach + void init() { + mockRequest = new MockHttpServletRequest(); + mockResponse = new MockHttpServletResponse(); + mockFilterChain = new MockFilterChain(); + + JwtProvider jwtProvider = new JwtProvider(jwtProperties); + jwtFilter = new JwtFilter(jwtProperties, jwtProvider); + + SimpleGrantedAuthority grantedAuthority = new SimpleGrantedAuthority(UserGrade.USER.name()); + Collection authorities = Collections.singleton(grantedAuthority); + User user = new User( + "kdhyo98@gmail.com", + "", + authorities + ); + TokenDTO tokenDto = jwtProvider.generateTokenDto(new UsernamePasswordAuthenticationToken(user, null, authorities)); + mockRequest.addHeader("Authorization", "Bearer " + tokenDto.getAccessToken()); + + SecurityContextHolder.clearContext(); + } + + @ParameterizedTest + @DisplayName("Header 정보가 올바르지 않을 경우") + @ValueSource(strings = {"Bearer tokenTest", "Bearer", "BearertokenTest"}) + void validateToken(String authorization) { + // given + mockRequest.removeHeader("Authorization"); + mockRequest.addHeader("Authorization", authorization); + + // when + // then + assertThatThrownBy(() -> jwtFilter.doFilterInternal(mockRequest, mockResponse, mockFilterChain)) + .isInstanceOf(RuntimeException.class); + } + + @Test + @DisplayName("다음 필터 실행") + void continuesToNextFilter() throws ServletException, IOException { + // given + MockFilterChain mockFilterChainSpy = spy(this.mockFilterChain); + + // when + jwtFilter.doFilter(mockRequest, mockResponse, mockFilterChainSpy); + + // then + verify(mockFilterChainSpy, times(1)).doFilter(mockRequest, mockResponse); + } + + @Test + @DisplayName("setAuthentication 데이터 확인") + void setsAuthenticationInSecurityContext() throws ServletException, IOException { + // given + SimpleGrantedAuthority grantedAuthority = new SimpleGrantedAuthority(ROLES.USER); + Collection authorities = Collections.singleton(grantedAuthority); + + // when + jwtFilter.doFilter(mockRequest, mockResponse, mockFilterChain); + + // then + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + User principal = (User) authentication.getPrincipal(); + assertAll( + () -> assertThat(principal.getUsername()).isEqualTo("kdhyo98@gmail.com"), + () -> assertThat(principal.getAuthorities()).isEqualTo(authorities) + ); + } + +}