diff --git a/src/main/java/com/cleanengine/coin/account/readme.md b/src/main/java/com/cleanengine/coin/account/readme.md deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/com/cleanengine/coin/user/info/application/UserService.java b/src/main/java/com/cleanengine/coin/user/info/application/UserService.java index 052e4730..625ce8a0 100644 --- a/src/main/java/com/cleanengine/coin/user/info/application/UserService.java +++ b/src/main/java/com/cleanengine/coin/user/info/application/UserService.java @@ -1,7 +1,5 @@ package com.cleanengine.coin.user.info.application; -import com.cleanengine.coin.user.info.infra.AccountRepository; -import com.cleanengine.coin.user.info.infra.WalletRepository; import com.cleanengine.coin.user.info.presentation.UserInfoDTO; import com.cleanengine.coin.user.info.infra.UserRepository; import org.springframework.stereotype.Service; @@ -10,13 +8,9 @@ public class UserService { private final UserRepository userRepository; - private final AccountRepository accountRepository; - private final WalletRepository walletRepository; - public UserService(UserRepository userRepository, AccountRepository accountRepository, WalletRepository walletRepository) { + public UserService(UserRepository userRepository) { this.userRepository = userRepository; - this.accountRepository = accountRepository; - this.walletRepository = walletRepository; } public UserInfoDTO retrieveUserInfoByUserId(Integer userId) { diff --git a/src/main/java/com/cleanengine/coin/user/info/presentation/UserController.java b/src/main/java/com/cleanengine/coin/user/info/presentation/UserController.java index 574cdfca..60580b4c 100644 --- a/src/main/java/com/cleanengine/coin/user/info/presentation/UserController.java +++ b/src/main/java/com/cleanengine/coin/user/info/presentation/UserController.java @@ -1,14 +1,14 @@ package com.cleanengine.coin.user.info.presentation; import com.cleanengine.coin.common.response.ApiResponse; +import com.cleanengine.coin.common.response.ErrorResponse; +import com.cleanengine.coin.common.response.ErrorStatus; import com.cleanengine.coin.user.domain.Account; import com.cleanengine.coin.user.domain.Wallet; import com.cleanengine.coin.user.info.application.AccountService; import com.cleanengine.coin.user.info.application.WalletService; -import com.cleanengine.coin.user.login.application.JWTUtil; import com.cleanengine.coin.user.info.application.UserService; import com.cleanengine.coin.user.login.infra.CustomOAuth2User; -import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -23,30 +23,30 @@ public class UserController { private final UserService userService; private final AccountService accountService; private final WalletService walletService; - private final JWTUtil jwtUtil; - public UserController(UserService userService, AccountService accountService, WalletService walletService, JWTUtil jwtUtil) { + public UserController(UserService userService, AccountService accountService, WalletService walletService) { this.userService = userService; this.accountService = accountService; this.walletService = walletService; - this.jwtUtil = jwtUtil; } @GetMapping("/api/userinfo") - public ApiResponse retrieveUserInfo(HttpServletRequest request) { + public ApiResponse retrieveUserInfo() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null && authentication.getPrincipal() instanceof CustomOAuth2User oAuth2User) { Integer userId = oAuth2User.getUserId(); UserInfoDTO userInfoDTO = userService.retrieveUserInfoByUserId(userId); + if (userInfoDTO == null) { + return ApiResponse.fail(ErrorResponse.of(ErrorStatus.UNAUTHORIZED_RESOURCE)); + } Account account = accountService.retrieveAccountByUserId(userId); List wallets = walletService.retrieveWalletsByAccountId(account.getId()); userInfoDTO.setWallets(wallets); return ApiResponse.success(userInfoDTO, HttpStatus.OK); } - - throw new IllegalStateException("인증된 사용자를 찾을 수 없습니다."); + return ApiResponse.fail(ErrorResponse.of(ErrorStatus.UNAUTHORIZED_RESOURCE)); } } diff --git a/src/main/java/com/cleanengine/coin/user/info/presentation/UserInfoDTO.java b/src/main/java/com/cleanengine/coin/user/info/presentation/UserInfoDTO.java index 29e96521..ca91c6ad 100644 --- a/src/main/java/com/cleanengine/coin/user/info/presentation/UserInfoDTO.java +++ b/src/main/java/com/cleanengine/coin/user/info/presentation/UserInfoDTO.java @@ -10,7 +10,6 @@ @Getter @Setter @NoArgsConstructor -@AllArgsConstructor public class UserInfoDTO { private Integer userId; @@ -26,4 +25,17 @@ public class UserInfoDTO { private List wallets; + private UserInfoDTO(Integer userId, String email, String nickname, String provider, Double cash, List wallets) { + this.userId = userId; + this.email = email; + this.nickname = nickname; + this.provider = provider; + this.cash = cash; + this.wallets = wallets; + } + + public static UserInfoDTO of(Integer userId, String email, String nickname, String provider, Double cash, List wallets) { + return new UserInfoDTO(userId, email, nickname, provider, cash, wallets); + } + } diff --git a/src/main/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserService.java b/src/main/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserService.java index fde4d463..d39e163d 100644 --- a/src/main/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserService.java +++ b/src/main/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserService.java @@ -33,11 +33,9 @@ public CustomOAuth2UserService(UserRepository userRepository, OAuthRepository oA public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { OAuth2User oAuth2User = super.loadUser(userRequest); - String registrationId = userRequest.getClientRegistration().getRegistrationId(); - OAuth2Response oAuth2Response = new KakaoResponse(oAuth2User.getAttributes()); /* 추후 OAuth 플랫폼 추가 시 이런 식으로 Response 분기처리 - if (registrationId.equals("kakao")) { + if (userRequest.getClientRegistration().getRegistrationId().equals("kakao")) { oAuth2Response = new KakaoResponse(oAuth2User.getAttributes()); } else { @@ -66,9 +64,7 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic oAuthRepository.save(newOAuth); accountService.createNewAccount(newUser.getId(), CommonValues.INITIAL_USER_CASH); - UserOAuthDetails userOAuthDetails = new UserOAuthDetails(newUser, newOAuth); - - return new CustomOAuth2User(userOAuthDetails); + return new CustomOAuth2User(UserOAuthDetails.of(newUser, newOAuth)); } else { OAuth existOAuth = oAuthRepository.findByProviderAndProviderUserId(provider, providerUserId); diff --git a/src/main/java/com/cleanengine/coin/user/login/application/JWTFilter.java b/src/main/java/com/cleanengine/coin/user/login/application/JWTFilter.java index d3b3193f..89e9e942 100644 --- a/src/main/java/com/cleanengine/coin/user/login/application/JWTFilter.java +++ b/src/main/java/com/cleanengine/coin/user/login/application/JWTFilter.java @@ -82,8 +82,7 @@ protected void doFilterInternal(HttpServletRequest request, @NotNull HttpServlet } private static Authentication getAuthentication(Integer userId) { - UserOAuthDetails userOAuthDetails = new UserOAuthDetails(); - userOAuthDetails.setUserId(userId); + UserOAuthDetails userOAuthDetails = UserOAuthDetails.of(userId); CustomOAuth2User customOAuth2User = new CustomOAuth2User(userOAuthDetails); // 스프링 시큐리티 인증 토큰 생성 diff --git a/src/main/java/com/cleanengine/coin/user/login/infra/UserOAuthDetails.java b/src/main/java/com/cleanengine/coin/user/login/infra/UserOAuthDetails.java index 998afff5..d90ef467 100644 --- a/src/main/java/com/cleanengine/coin/user/login/infra/UserOAuthDetails.java +++ b/src/main/java/com/cleanengine/coin/user/login/infra/UserOAuthDetails.java @@ -2,15 +2,10 @@ import com.cleanengine.coin.user.domain.OAuth; import com.cleanengine.coin.user.domain.User; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import lombok.*; @Getter @Setter -@NoArgsConstructor -@AllArgsConstructor public class UserOAuthDetails { private Integer userId; @@ -23,11 +18,29 @@ public class UserOAuthDetails { private String name; - public UserOAuthDetails(User user, OAuth oAuth) { - this.userId = user.getId(); - this.provider = oAuth.getProvider(); - this.providerUserId = oAuth.getProviderUserId(); - this.email = oAuth.getEmail(); - this.name = oAuth.getNickname(); + @Builder + private UserOAuthDetails(Integer userId, String provider, String providerUserId, String email, String name) { + this.userId = userId; + this.provider = provider; + this.providerUserId = providerUserId; + this.email = email; + this.name = name; } + + public static UserOAuthDetails of(User user, OAuth oAuth) { + return UserOAuthDetails.builder() + .userId(user.getId()) + .provider(oAuth.getProvider()) + .providerUserId(oAuth.getProviderUserId()) + .email(oAuth.getEmail()) + .name(oAuth.getNickname()) + .build(); + } + + public static UserOAuthDetails of(int userId) { + return UserOAuthDetails.builder() + .userId(userId) + .build(); + } + } diff --git a/src/test/java/com/cleanengine/coin/tool/helper/WithCustomMockUserSecurityContextFactory.java b/src/test/java/com/cleanengine/coin/tool/helper/WithCustomMockUserSecurityContextFactory.java index d2636a56..96695066 100644 --- a/src/test/java/com/cleanengine/coin/tool/helper/WithCustomMockUserSecurityContextFactory.java +++ b/src/test/java/com/cleanengine/coin/tool/helper/WithCustomMockUserSecurityContextFactory.java @@ -14,10 +14,7 @@ public class WithCustomMockUserSecurityContextFactory implements WithSecurityCon public SecurityContext createSecurityContext(WithCustomMockUser annotation) { SecurityContext context = SecurityContextHolder.createEmptyContext(); - UserOAuthDetails userOAuthDetails = new UserOAuthDetails(); - userOAuthDetails.setUserId(annotation.id()); - userOAuthDetails.setName(annotation.name()); - + UserOAuthDetails userOAuthDetails = UserOAuthDetails.of(annotation.id()); CustomOAuth2User customOAuth2User = new CustomOAuth2User(userOAuthDetails); Authentication authentication = new UsernamePasswordAuthenticationToken(customOAuth2User, diff --git a/src/test/java/com/cleanengine/coin/user/domain/AccountTest.java b/src/test/java/com/cleanengine/coin/user/domain/AccountTest.java index 40dab6a9..b3ee2fe3 100644 --- a/src/test/java/com/cleanengine/coin/user/domain/AccountTest.java +++ b/src/test/java/com/cleanengine/coin/user/domain/AccountTest.java @@ -6,6 +6,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.*; +@DisplayName("계좌 단위테스트") class AccountTest { @DisplayName("계좌의 예수금을 5000만큼 증가시킨다.") @@ -40,10 +41,7 @@ void decreaseCash() { Account account = Account.of(1, 6000.0); // when, then - account.decreaseCash(5000.0); - - // then - assertEquals(1000.0, account.getCash()); + assertEquals(1000.0, account.decreaseCash(5000.0).getCash()); } @DisplayName("계좌의 예수금을 0만큼 감소시키면 예외가 발생한다.") diff --git a/src/test/java/com/cleanengine/coin/user/info/application/AccountServiceTest.java b/src/test/java/com/cleanengine/coin/user/info/application/AccountServiceTest.java index 35c4b078..1a5efff8 100644 --- a/src/test/java/com/cleanengine/coin/user/info/application/AccountServiceTest.java +++ b/src/test/java/com/cleanengine/coin/user/info/application/AccountServiceTest.java @@ -11,6 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat; @ActiveProfiles({"dev", "it", "h2-mem"}) +@DisplayName("계좌 서비스 - h2 통합테스트") @SpringBootTest class AccountServiceTest { @@ -19,19 +20,31 @@ class AccountServiceTest { @DisplayName("유저 ID와 예수금으로 신규 계좌를 생성한다.") @Test - void test() { + void createNewAccount() { // given int userId = 3; double cash = CommonValues.INITIAL_USER_CASH; // when - accountService.createNewAccount(userId, cash); - Account account = accountService.retrieveAccountByUserId(userId); + Account account = accountService.createNewAccount(userId, cash); + assertThat(account).isNotNull(); + + Account retrievedAccount = accountService.retrieveAccountByUserId(userId); // then - assertThat(account).isNotNull() + assertThat(retrievedAccount).isNotNull() .extracting(Account::getUserId, Account::getCash) .containsExactly(userId, cash); } + @DisplayName("존재하지 않는 userId로 조회 시 null을 반환한다.") + @Test + void retrieveAccountByInvalidUserId() { + // given, when + Account account = accountService.retrieveAccountByUserId(1000); + + // then + assertThat(account).isNull(); + } + } \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/user/info/presentation/UserControllerTest.java b/src/test/java/com/cleanengine/coin/user/info/presentation/UserControllerTest.java new file mode 100644 index 00000000..7e77629e --- /dev/null +++ b/src/test/java/com/cleanengine/coin/user/info/presentation/UserControllerTest.java @@ -0,0 +1,115 @@ +package com.cleanengine.coin.user.info.presentation; + +import com.cleanengine.coin.configuration.SecurityEndpoints; +import com.cleanengine.coin.user.domain.Account; +import com.cleanengine.coin.user.info.application.AccountService; +import com.cleanengine.coin.user.info.application.UserService; +import com.cleanengine.coin.user.info.application.WalletService; +import com.cleanengine.coin.user.login.application.CustomOAuth2UserService; +import com.cleanengine.coin.user.login.application.CustomSuccessHandler; +import com.cleanengine.coin.user.login.application.JWTUtil; +import com.cleanengine.coin.user.login.infra.CustomOAuth2User; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.stubbing.Answer; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +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.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; + +import java.util.Collection; +import java.util.List; + +import static org.hamcrest.Matchers.is; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +@WebMvcTest(UserController.class) +public class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private UserService userService; + + @MockitoBean + private AccountService accountService; + + @MockitoBean + private WalletService walletService; + + @MockitoBean + private JWTUtil jwtUtil; + + @MockitoBean + private CustomOAuth2UserService customOAuth2UserService; + + @MockitoBean + private CustomSuccessHandler customSuccessHandler; + + @MockitoBean + private SecurityEndpoints.EndpointConfig endpointConfig; + + @Mock + private CustomOAuth2User customOAuth2User; + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("정상적으로 존재하는 사용자 정보를 통해 조회에 성공한다.") + public void testRetrieveUserInfoSuccess() throws Exception { + int userId = 1; + String email = "test@test.com"; + String nickname = "test"; + String provider = "kakao"; + double cash = 1000.0; + + when(customOAuth2User.getUserId()).thenReturn(userId); + when(customOAuth2User.getAttributes()).thenReturn(null); + Collection authorities = List.of(new SimpleGrantedAuthority("ROLE_USER")); + when(customOAuth2User.getAuthorities()).thenAnswer((Answer>) invocation -> authorities) + ; + + Authentication authenticationToken = new UsernamePasswordAuthenticationToken( + customOAuth2User, null, authorities + ); + + UserInfoDTO userInfoDTO = UserInfoDTO.of(userId, email, nickname, provider, cash, null); + when(userService.retrieveUserInfoByUserId(userId)).thenReturn(userInfoDTO); + + Account account = Account.of(userId, cash); + when(accountService.retrieveAccountByUserId(userId)).thenReturn(account); + + mockMvc.perform(get("/api/userinfo") + .with(authentication(authenticationToken))) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.isSuccess", is(true))) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.cash", is((int) cash))); + + verify(userService, times(1)).retrieveUserInfoByUserId(userId); + verify(accountService, times(1)).retrieveAccountByUserId(userId); + verify(walletService, times(1)).retrieveWalletsByAccountId(account.getId()); + } + + @Test + @DisplayName("인증되지 않은 사용자가 private api 접근 시 리디렉션 응답을 반환한다.") + public void testRetrieveUserInfoUnauthorized() throws Exception { + mockMvc.perform(get("/api/userinfo")) + .andExpect(MockMvcResultMatchers.status().is3xxRedirection()); + verifyNoInteractions(userService, accountService, walletService); + } + +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/user/login/application/JWTUtilTest.java b/src/test/java/com/cleanengine/coin/user/login/application/JWTUtilTest.java new file mode 100644 index 00000000..e3ddb2b9 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/user/login/application/JWTUtilTest.java @@ -0,0 +1,50 @@ +package com.cleanengine.coin.user.login.application; + +import io.jsonwebtoken.ExpiredJwtException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; + +class JWTUtilTest { + + private final String secretKey = "secret-key-secret-key-secret-key-secret-key-secret-key-secret-key"; + private final JWTUtil jwtUtil = new JWTUtil(secretKey); + + @DisplayName("유저 ID와 유효기한으로 JWT를 생성한다.") + @Test + void createJwt() { + // given + int userId = 3; + Long expiredMs = 1000L; + + // when + String jwt = jwtUtil.createJwt(userId, expiredMs); + + // then + assertThat(jwt).isNotNull(); + assertThat(jwtUtil.getUserId(jwt)).isEqualTo(userId); + assertFalse(jwtUtil.isExpired(jwt)); + } + + @DisplayName("만료된 JWT를 감지한다.") + @Test + void expiredJwt() throws InterruptedException { + // given + int userId = 3; + Long expiredMs = 1L; + + // when + String jwt = jwtUtil.createJwt(userId, expiredMs); + + // then + Thread.sleep(2L); + assertThat(jwt).isNotNull(); + assertThatThrownBy(() -> jwtUtil.isExpired(jwt)) + .isInstanceOf(ExpiredJwtException.class); + } + + // 위조 검증 (userId, 만료기한, secret key 각각) +} \ No newline at end of file