From d885f3adcdd4fe7f1188ad6b67352454b5d77218 Mon Sep 17 00:00:00 2001 From: RosieOh <20172207@gm.hannam.ac.kr> Date: Tue, 22 Sep 2026 14:24:56 +0900 Subject: [PATCH] =?UTF-8?q?FIX=20:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20=EC=99=84=EB=A3=8C=20=EB=B3=B5=EA=B5=AC?= =?UTF-8?q?=EC=99=80=20=EC=97=AD=ED=95=A0=20=EA=B6=8C=ED=95=9C=20=EC=83=81?= =?UTF-8?q?=EC=8A=B9=20=EC=B0=A8=EB=8B=A8,=20UserService=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20(#93,=20#103)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UserService 테스트를 쓰다가 카카오 가입 완료 경로에서 결함 두 개를 찾았다. 하나만 고치면 더 나빠지는 구조라 함께 고친다. 1) 카카오 신규 가입이 전부 막혀 있었다. 카카오 로그인은 가입 미완료 사용자에게도 토큰을 주고, 프런트는 그 토큰으로 /auth/kakao/complete-registration 을 부른다. 그런데 JWT 필터가 path.startsWith("/auth/kakao") 로 통째로 건너뛰어 토큰이 해석되지 않았고, 컨트롤러가 현재 사용자를 찾다가 401 을 냈다. 통합 테스트로 재현했다 (유효한 토큰으로 호출해도 expected 200 but was 401). 필터 예외를 /auth/kakao/login, /auth/kakao/login-url 로 좁히고, SecurityConfig 에서 가입 완료를 authenticated() 로 바꿨다. 2) 역할을 클라이언트가 정했다. 요청 본문의 role 을 UserRole.valueOf 로 그대로 저장했고, 오류 메시지가 "가능한 역할: PARENT, CAREGIVER, ADMIN, GUEST" 라고 알려주기까지 했다. {"role":"ADMIN"} 이면 그 자리에서 관리자다. 이메일 가입의 #82 와 같다. 지금까지 드러나지 않은 건 1번이 흐름을 막고 있었기 때문이다. 1번만 고치면 그대로 열린다. 스스로 고를 수 있는 역할을 허용 목록(PARENT, CAREGIVER)으로 제한했다. 금지 목록이 아니라 허용 목록이라, 나중에 특권 역할이 추가돼도 기본으로 막힌다. 프런트 가입 화면은 PARENT 로 고정해 보내므로 깨지지 않는다. 3) Boolean 언박싱 3곳을 null 안전하게 했다. registrationCompleted / isActive 가 Boolean 인데 !x.get...(), x.get...() && 로 언박싱했다. null 이면 카카오 로그인(AuthServiceImpl), 가입 완료, 계정 복구가 NPE 로 500 이었다. UserService 테스트 (#93) 라인 커버리지 0.7% → 95.8%. 0.7% 였던 동안 이 클래스에서 P0 가 세 번 나왔다 (#82 가입 role, #83 역할 변경 노출, 그리고 이번 카카오 role). build.gradle 의 CLASS 60% 규칙에 UserService 를 넣고, 전체 ratchet 을 0.30 → 0.35 로 올렸다. @PreAuthorize 는 스프링 프록시가 적용하므로 단위 테스트 범위 밖이다. 그 계약은 접근제어 통합 테스트가 맡는다. 460 tests, 0 failures. 전체 라인 커버리지 34.9% → 37.8%. --- build.gradle | 8 +- .../security/JwtAuthenticationFilter.java | 6 +- .../core/security/SecurityConfig.java | 4 +- .../domain/user/service/UserService.java | 32 +- .../user/service/impl/AuthServiceImpl.java | 3 +- .../domain/user/service/UserServiceTest.java | 571 ++++++++++++++++++ .../EndpointAuthorizationCoverageTest.java | 4 +- .../KakaoRegistrationCompletionTest.java | 173 ++++++ 8 files changed, 787 insertions(+), 14 deletions(-) create mode 100644 src/test/java/com/carecode/domain/user/service/UserServiceTest.java create mode 100644 src/test/java/com/carecode/integration/KakaoRegistrationCompletionTest.java diff --git a/build.gradle b/build.gradle index 4d0c7d80..9517c2f8 100644 --- a/build.gradle +++ b/build.gradle @@ -138,14 +138,14 @@ tasks.named('jacocoTestCoverageVerification') { violationRules { // 1) 전체 커버리지: 후퇴 방지선(ratchet). - // 현재 라인 커버리지는 약 34% 다. 테스트를 늘릴 때마다 이 값을 함께 올린다. + // 현재 라인 커버리지는 약 37% 다. 테스트를 늘릴 때마다 이 값을 함께 올린다. // (기존 0.01 은 사실상 게이트가 없는 것과 같아 통과해도 의미가 없었다.) rule { element = 'BUNDLE' limit { counter = 'LINE' value = 'COVEREDRATIO' - minimum = 0.30 + minimum = 0.35 } } @@ -157,6 +157,7 @@ tasks.named('jacocoTestCoverageVerification') { // - JwtAuthenticationFilter: 누구로 인증되는지를 정한다. // - RateLimitInterceptor: fail-open 이 깨지면 Redis 장애가 전면 장애가 된다. // - EmailVerificationService: 인증번호 난수·시도 제한. + // - UserService: 가입·역할·계정 상태. 0.7% 였던 동안 여기서 P0 가 세 번 나왔다. rule { element = 'CLASS' includes = [ @@ -166,7 +167,8 @@ tasks.named('jacocoTestCoverageVerification') { 'com.carecode.core.security.CurrentUserFacade', 'com.carecode.core.security.JwtAuthenticationFilter', 'com.carecode.core.RateLimitInterceptor', - 'com.carecode.domain.user.service.EmailVerificationService' + 'com.carecode.domain.user.service.EmailVerificationService', + 'com.carecode.domain.user.service.UserService' ] limit { counter = 'LINE' diff --git a/src/main/java/com/carecode/core/security/JwtAuthenticationFilter.java b/src/main/java/com/carecode/core/security/JwtAuthenticationFilter.java index d50630f4..544c7b67 100644 --- a/src/main/java/com/carecode/core/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/carecode/core/security/JwtAuthenticationFilter.java @@ -115,7 +115,11 @@ protected boolean shouldNotFilter(HttpServletRequest request) throws ServletExce path.startsWith("/auth/login") || path.startsWith("/auth/register") || path.equals("/auth/refresh") || - path.startsWith("/auth/kakao") || + // /auth/kakao 전체가 아니다. 가입 완료(/auth/kakao/complete-registration)는 + // 카카오 로그인이 발급한 토큰으로 호출되는데, 접두사로 통째로 건너뛰면 토큰이 + // 해석되지 않아 항상 401 이었다. 카카오로 처음 들어온 사람은 가입을 끝낼 수 없었다. + path.equals("/auth/kakao/login") || + path.equals("/auth/kakao/login-url") || path.startsWith("/oauth2") || path.startsWith("/login/oauth2") || path.equals("/kakao-callback.html"); diff --git a/src/main/java/com/carecode/core/security/SecurityConfig.java b/src/main/java/com/carecode/core/security/SecurityConfig.java index b1519b08..051bd9a2 100644 --- a/src/main/java/com/carecode/core/security/SecurityConfig.java +++ b/src/main/java/com/carecode/core/security/SecurityConfig.java @@ -110,7 +110,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/auth/refresh").permitAll() // 토큰 갱신 .requestMatchers("/auth/kakao/login").permitAll() // 카카오 로그인 .requestMatchers("/auth/kakao/login-url").permitAll() // 카카오 로그인 URL 생성 - .requestMatchers("/auth/kakao/complete-registration").permitAll() // 카카오 가입 완료 + // 카카오 가입 완료는 로그인한 사용자가 자기 계정에 하는 동작이다(대상은 토큰의 이메일). + // permitAll 로 두면 JWT 필터 예외와 겹쳐 인증 없이 들어오고, 컨트롤러가 401 을 냈다. + .requestMatchers("/auth/kakao/complete-registration").authenticated() // OAuth2 authorize/token (Spring Client beans). .requestMatchers("/oauth2/**").permitAll() diff --git a/src/main/java/com/carecode/domain/user/service/UserService.java b/src/main/java/com/carecode/domain/user/service/UserService.java index ebfb2428..8d4b9ca4 100644 --- a/src/main/java/com/carecode/domain/user/service/UserService.java +++ b/src/main/java/com/carecode/domain/user/service/UserService.java @@ -43,6 +43,13 @@ public class UserService { /** 자가 가입으로 만들어질 수 있는 유일한 역할. 그 이상은 관리자만 부여한다. */ private static final UserRole SELF_SIGNUP_ROLE = UserRole.PARENT; + /** + * 가입 과정에서 본인이 고를 수 있는 역할. 프런트 가입 화면은 PARENT 로 고정해 보낸다. + * ADMIN 은 관리자만 부여할 수 있다(PUT /api/admin/users/{id}/role). + */ + private static final java.util.Set SELF_SELECTABLE_ROLES = + java.util.EnumSet.of(UserRole.PARENT, UserRole.CAREGIVER); + private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final RestTemplate restTemplate; @@ -203,17 +210,28 @@ public UserDto completeKakaoRegistration(String email, String name, String role) throw new IllegalArgumentException("카카오 사용자가 아닙니다: " + email); } - // 이미 가입 완료된 사용자인지 확인 - if (user.getRegistrationCompleted()) { + // 이미 가입 완료된 사용자인지 확인 (Boolean 이라 null 이면 언박싱에서 터진다) + if (Boolean.TRUE.equals(user.getRegistrationCompleted())) { throw new IllegalArgumentException("이미 가입 완료된 사용자입니다: " + email); } - - // 역할 유효성 검증 + + // 역할은 스스로 고를 수 있는 것만 받는다. + // + // 예전에는 요청 본문의 role 을 UserRole.valueOf 로 그대로 받았고, 오류 메시지가 + // "가능한 역할: PARENT, CAREGIVER, ADMIN, GUEST" 라고 친절히 알려주기까지 했다. + // 즉 {"role":"ADMIN"} 으로 가입을 끝내면 그 자리에서 관리자가 됐다(이메일 가입의 #82 와 같다). + // 한동안 드러나지 않은 건 JWT 필터가 이 경로를 건너뛰어 흐름 자체가 401 로 막혀 있었기 때문이다. + // + // 금지 목록이 아니라 허용 목록이다. 나중에 특권 역할이 추가돼도 기본으로 막힌다. UserRole userRole; try { - userRole = UserRole.valueOf(role); + userRole = UserRole.valueOf(role.trim().toUpperCase()); } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("유효하지 않은 역할입니다: " + role + ". 가능한 역할: PARENT, CAREGIVER, ADMIN, GUEST"); + throw new IllegalArgumentException("유효하지 않은 역할입니다: " + role); + } + if (!SELF_SELECTABLE_ROLES.contains(userRole)) { + log.warn("카카오 가입 완료에서 스스로 고를 수 없는 역할 요청 - email={}, role={}", email, userRole); + throw new IllegalArgumentException("선택할 수 없는 역할입니다: " + role); } // 이름 및 역할 업데이트 및 가입 프로세스 완료 처리 @@ -566,7 +584,7 @@ public void reactivateUser(String userId) { } // 이미 활성화된 계정인지 확인 - if (user.getIsActive() && user.getDeletedAt() == null) { + if (Boolean.TRUE.equals(user.getIsActive()) && user.getDeletedAt() == null) { throw new IllegalArgumentException("이미 활성화된 계정입니다."); } diff --git a/src/main/java/com/carecode/domain/user/service/impl/AuthServiceImpl.java b/src/main/java/com/carecode/domain/user/service/impl/AuthServiceImpl.java index 48dcf5f1..dd9cd796 100644 --- a/src/main/java/com/carecode/domain/user/service/impl/AuthServiceImpl.java +++ b/src/main/java/com/carecode/domain/user/service/impl/AuthServiceImpl.java @@ -82,7 +82,8 @@ public TokenDto oAuthLoginOrRegister(String accessCode) { user.setUpdatedAt(LocalDateTime.now()); userRepository.save(user); - if ("kakao".equals(user.getProvider()) && !user.getRegistrationCompleted()) { + // registrationCompleted 는 Boolean 이다. null 이면 !언박싱에서 NPE 로 로그인 전체가 500 이 된다. + if ("kakao".equals(user.getProvider()) && !Boolean.TRUE.equals(user.getRegistrationCompleted())) { isNewUser = true; } } else { diff --git a/src/test/java/com/carecode/domain/user/service/UserServiceTest.java b/src/test/java/com/carecode/domain/user/service/UserServiceTest.java new file mode 100644 index 00000000..a1a376aa --- /dev/null +++ b/src/test/java/com/carecode/domain/user/service/UserServiceTest.java @@ -0,0 +1,571 @@ +package com.carecode.domain.user.service; + +import com.carecode.core.analytics.EventLogger; +import com.carecode.core.exception.UserNotFoundException; +import com.carecode.domain.user.dto.request.PasswordChangeRequestDto; +import com.carecode.domain.user.dto.response.UserDto; +import com.carecode.domain.user.dto.response.UserStatsResponse; +import com.carecode.domain.user.entity.Gender; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.entity.UserRole; +import com.carecode.domain.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * UserService 단위 테스트. + * + *

이 클래스는 인증·계정 상태·역할이 모인 곳인데 라인 커버리지가 0.7% 였다. + * 그 사이에 여기서 P0 가 세 번 나왔다. + *

+ * + *

가입 시 서버가 정하는 값은 {@link UserServiceSignUpTest} 에 있다. 여기서는 나머지를 다룬다. + * {@code @PreAuthorize} 는 스프링 프록시가 적용하므로 단위 테스트 범위 밖이다 — + * 그 계약은 접근제어 통합 테스트가 맡는다. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("UserService") +class UserServiceTest { + + @Mock private UserRepository userRepository; + @Mock private PasswordEncoder passwordEncoder; + @Mock private RestTemplate restTemplate; + @Mock private EventLogger eventLogger; + + @InjectMocks private UserService userService; + + private User user; + + @BeforeEach + void setUp() { + user = User.builder() + .id(1L) + .userId("user_1") + .email("me@example.com") + .password("$2a$hash") + .name("엄마") + .gender(Gender.FEMALE) + .birthDate(LocalDate.of(1990, 1, 1)) + .role(UserRole.PARENT) + .isActive(true) + .emailVerified(true) + .registrationCompleted(true) + .createdAt(LocalDateTime.now().minusDays(3)) + .build(); + + when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(userRepository.findByEmailAndDeletedAtIsNull("me@example.com")).thenReturn(Optional.of(user)); + } + + private User savedUser() { + ArgumentCaptor captor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(captor.capture()); + return captor.getValue(); + } + + // ===================================================================== + + @Nested + @DisplayName("카카오 가입 완료") + class CompleteKakaoRegistration { + + private User kakaoNewbie; + + @BeforeEach + void setUp() { + kakaoNewbie = User.builder() + .id(2L).userId("user_k").email("kakao@example.com").name("카카오_1") + .role(UserRole.PARENT).provider("kakao").providerId("1") + .isActive(true).emailVerified(true).registrationCompleted(false) + .build(); + when(userRepository.findByEmailAndDeletedAtIsNull("kakao@example.com")) + .thenReturn(Optional.of(kakaoNewbie)); + } + + @ParameterizedTest(name = "{0} 로 가입을 끝낼 수 있다") + @ValueSource(strings = {"PARENT", "CAREGIVER", "parent"}) + @DisplayName("스스로 고를 수 있는 역할이면 가입을 끝낸다") + void acceptsSelfSelectableRoles(String role) { + UserDto dto = userService.completeKakaoRegistration("kakao@example.com", "맘편한", role); + + User saved = savedUser(); + assertThat(saved.getRegistrationCompleted()).isTrue(); + assertThat(saved.getName()).isEqualTo("맘편한"); + assertThat(saved.getRole()).isEqualTo(UserRole.valueOf(role.toUpperCase())); + assertThat(dto.getRegistrationCompleted()).isTrue(); + } + + @ParameterizedTest(name = "{0} 은 거부한다") + @ValueSource(strings = {"ADMIN", "admin", "GUEST"}) + @DisplayName("관리자 등 스스로 고를 수 없는 역할은 거부하고 아무것도 저장하지 않는다") + void rejectsPrivilegedRoles(String role) { + assertThatThrownBy(() -> userService.completeKakaoRegistration("kakao@example.com", "맘편한", role)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("선택할 수 없는 역할"); + + verify(userRepository, never()).save(any()); + assertThat(kakaoNewbie.getRole()).isEqualTo(UserRole.PARENT); + } + + @Test + @DisplayName("존재하지 않는 역할 문자열은 400 으로 끝난다") + void rejectsUnknownRole() { + assertThatThrownBy(() -> userService.completeKakaoRegistration("kakao@example.com", "맘편한", "SUPERUSER")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("유효하지 않은 역할"); + } + + @Test + @DisplayName("이름·역할은 필수다") + void requiresNameAndRole() { + assertThatThrownBy(() -> userService.completeKakaoRegistration("kakao@example.com", " ", "PARENT")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> userService.completeKakaoRegistration("kakao@example.com", "맘편한", null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("카카오 사용자가 아니면 거부한다") + void rejectsNonKakaoUser() { + assertThatThrownBy(() -> userService.completeKakaoRegistration("me@example.com", "맘편한", "PARENT")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("카카오 사용자가 아닙니다"); + } + + @Test + @DisplayName("이미 가입을 끝낸 사용자는 다시 할 수 없다 — 역할을 바꾸는 우회로가 되면 안 된다") + void rejectsAlreadyCompleted() { + kakaoNewbie.setRegistrationCompleted(true); + + assertThatThrownBy(() -> userService.completeKakaoRegistration("kakao@example.com", "맘편한", "CAREGIVER")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("이미 가입 완료"); + verify(userRepository, never()).save(any()); + } + + @Test + @DisplayName("가입 완료 여부가 null 이어도 터지지 않는다") + void toleratesNullRegistrationFlag() { + // Boolean 이라 !user.getRegistrationCompleted() 는 null 에서 NPE 였다. + kakaoNewbie.setRegistrationCompleted(null); + + userService.completeKakaoRegistration("kakao@example.com", "맘편한", "PARENT"); + + assertThat(savedUser().getRegistrationCompleted()).isTrue(); + } + + @Test + @DisplayName("사용자가 없으면 UserNotFound") + void missingUser() { + when(userRepository.findByEmailAndDeletedAtIsNull("nobody@example.com")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.completeKakaoRegistration("nobody@example.com", "맘편한", "PARENT")) + .isInstanceOf(UserNotFoundException.class); + } + } + + // ===================================================================== + + @Nested + @DisplayName("비밀번호 변경") + class ChangePassword { + + @Test + @DisplayName("현재 비밀번호가 맞으면 새 비밀번호를 해시해서 저장한다") + void changesPassword() { + when(passwordEncoder.matches("old", "$2a$hash")).thenReturn(true); + when(passwordEncoder.encode("new-password")).thenReturn("$2a$new"); + + userService.changePassword("1", new PasswordChangeRequestDto("old", "new-password")); + + assertThat(savedUser().getPassword()).isEqualTo("$2a$new"); + } + + @Test + @DisplayName("현재 비밀번호가 틀리면 바꾸지 않는다") + void rejectsWrongCurrentPassword() { + when(passwordEncoder.matches("wrong", "$2a$hash")).thenReturn(false); + + assertThatThrownBy(() -> userService.changePassword("1", new PasswordChangeRequestDto("wrong", "new"))) + .isInstanceOf(IllegalArgumentException.class); + verify(userRepository, never()).save(any()); + } + } + + // ===================================================================== + + @Nested + @DisplayName("계정 상태") + class AccountState { + + @Test + @DisplayName("비활성화하면 isActive 가 false 가 된다") + void deactivates() { + userService.deactivateUser("1"); + assertThat(savedUser().getIsActive()).isFalse(); + } + + @Test + @DisplayName("활성화하면 isActive 가 true 가 된다") + void activates() { + user.setIsActive(false); + userService.activateUser("1"); + assertThat(savedUser().getIsActive()).isTrue(); + } + + @Test + @DisplayName("숫자가 아닌 ID 는 400") + void rejectsNonNumericId() { + assertThatThrownBy(() -> userService.deactivateUser("abc")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> userService.activateUser("abc")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> userService.updateProfileImage("abc", "x")).isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("없는 사용자는 UserNotFound") + void missingUser() { + when(userRepository.findById(404L)).thenReturn(Optional.empty()); + assertThatThrownBy(() -> userService.deactivateUser("404")).isInstanceOf(UserNotFoundException.class); + } + + @Test + @DisplayName("소프트 삭제는 deletedAt 을 채우고 비활성화한다 (userId 로 찾는다)") + void softDeletesByUserId() { + when(userRepository.findByUserIdAndDeletedAtIsNull("user_1")).thenReturn(Optional.of(user)); + + userService.deleteUser("user_1"); + + User saved = savedUser(); + assertThat(saved.getDeletedAt()).isNotNull(); + assertThat(saved.getIsActive()).isFalse(); + } + + @Test + @DisplayName("소프트 삭제는 DB PK 로도 찾는다") + void softDeletesByDatabaseId() { + when(userRepository.findByUserIdAndDeletedAtIsNull("1")).thenReturn(Optional.empty()); + + userService.deleteUser("1"); + + assertThat(savedUser().getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("이미 삭제된 사용자는 다시 삭제하지 않는다") + void refusesDoubleDelete() { + user.setDeletedAt(LocalDateTime.now()); + when(userRepository.findByUserIdAndDeletedAtIsNull("1")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.deleteUser("1")).isInstanceOf(UserNotFoundException.class); + verify(userRepository, never()).save(any()); + } + + @Test + @DisplayName("복구하면 deletedAt 을 비우고 활성화한다") + void reactivates() { + user.setIsActive(false); + user.setDeletedAt(LocalDateTime.now()); + when(userRepository.findByUserId("user_1")).thenReturn(Optional.of(user)); + + userService.reactivateUser("user_1"); + + User saved = savedUser(); + assertThat(saved.getIsActive()).isTrue(); + assertThat(saved.getDeletedAt()).isNull(); + } + + @Test + @DisplayName("이미 활성 계정은 복구 대상이 아니다") + void refusesReactivatingActive() { + when(userRepository.findByUserId("user_1")).thenReturn(Optional.of(user)); + + assertThatThrownBy(() -> userService.reactivateUser("user_1")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("isActive 가 null 이어도 복구가 터지지 않는다") + void reactivateToleratesNullActiveFlag() { + // 예전에는 user.getIsActive() 언박싱에서 NPE 였다. + user.setIsActive(null); + when(userRepository.findByUserId("user_1")).thenReturn(Optional.of(user)); + + userService.reactivateUser("user_1"); + + assertThat(savedUser().getIsActive()).isTrue(); + } + } + + // ===================================================================== + + @Nested + @DisplayName("역할 변경 (관리자 전용)") + class UpdateRole { + + @Test + @DisplayName("대소문자와 무관하게 역할을 바꾼다") + void updatesRole() { + userService.updateUserRole(1L, "caregiver"); + assertThat(savedUser().getRole()).isEqualTo(UserRole.CAREGIVER); + } + + @Test + @DisplayName("없는 역할이면 400 이고 저장하지 않는다") + void rejectsUnknownRole() { + assertThatThrownBy(() -> userService.updateUserRole(1L, "ROOT")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("지원하지 않는 역할"); + verify(userRepository, never()).save(any()); + } + + @Test + @DisplayName("없는 사용자는 UserNotFound") + void missingUser() { + when(userRepository.findById(404L)).thenReturn(Optional.empty()); + assertThatThrownBy(() -> userService.updateUserRole(404L, "ADMIN")) + .isInstanceOf(UserNotFoundException.class); + } + } + + // ===================================================================== + + @Nested + @DisplayName("프로필·위치") + class ProfileAndLocation { + + @Test + @DisplayName("프로필 이미지 URL 을 바꾼다") + void updatesProfileImage() { + userService.updateProfileImage("1", "/files/profile-images/a.png"); + assertThat(savedUser().getProfileImageUrl()).isEqualTo("/files/profile-images/a.png"); + } + + @Test + @DisplayName("위치를 바꾸고 결과를 돌려준다") + void updatesLocation() { + userService.updateUserLocation("1", 37.5, 127.0); + + User saved = savedUser(); + assertThat(saved.getLatitude()).isEqualTo(37.5); + assertThat(saved.getLongitude()).isEqualTo(127.0); + } + } + + // ===================================================================== + + @Nested + @DisplayName("조회") + class Queries { + + @Test + @DisplayName("userId 로 먼저 찾는다") + void getsByUserId() { + when(userRepository.findByUserIdAndDeletedAtIsNull("user_1")).thenReturn(Optional.of(user)); + assertThat(userService.getUserById("user_1").getEmail()).isEqualTo("me@example.com"); + } + + @Test + @DisplayName("userId 로 없으면 DB PK 로 찾는다") + void fallsBackToDatabaseId() { + when(userRepository.findByUserIdAndDeletedAtIsNull("1")).thenReturn(Optional.empty()); + assertThat(userService.getUserById("1").getId()).isEqualTo(1L); + } + + @Test + @DisplayName("삭제된 사용자는 조회되지 않는다") + void hidesDeletedUser() { + user.setDeletedAt(LocalDateTime.now()); + when(userRepository.findByUserIdAndDeletedAtIsNull("1")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.getUserById("1")).isInstanceOf(UserNotFoundException.class); + } + + @Test + @DisplayName("이메일로 조회한다") + void getsByEmail() { + assertThat(userService.getUserByEmail("me@example.com").getName()).isEqualTo("엄마"); + assertThat(userService.getUserEntityByEmail("me@example.com")).isSameAs(user); + assertThat(userService.getUserByEmailOptional("me@example.com")).contains(user); + assertThat(userService.findActiveUserEntityByEmail("me@example.com")).contains(user); + } + + @Test + @DisplayName("없는 이메일은 UserNotFound") + void missingEmail() { + when(userRepository.findByEmailAndDeletedAtIsNull("x@example.com")).thenReturn(Optional.empty()); + assertThatThrownBy(() -> userService.getUserByEmail("x@example.com")).isInstanceOf(UserNotFoundException.class); + assertThatThrownBy(() -> userService.getUserEntityByEmail("x@example.com")).isInstanceOf(UserNotFoundException.class); + } + + @Test + @DisplayName("검색 타입은 name / email 만 받는다") + void searchByType() { + when(userRepository.findByNameContainingAndDeletedAtIsNull("엄")).thenReturn(List.of(user)); + when(userRepository.findByEmailContainingAndDeletedAtIsNull("me")).thenReturn(List.of(user)); + + assertThat(userService.searchUsers("엄", "NAME")).hasSize(1); + assertThat(userService.searchUsers("me", "email")).hasSize(1); + assertThatThrownBy(() -> userService.searchUsers("x", "phone")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("통합 검색·목록 조회는 DTO 로 변환해 돌려준다") + void listQueries() { + when(userRepository.findByNameContainingOrEmailContainingAndDeletedAtIsNull("me", "me")).thenReturn(List.of(user)); + when(userRepository.findByIsActiveTrue()).thenReturn(List.of(user)); + when(userRepository.findByRole("PARENT")).thenReturn(List.of(user)); + when(userRepository.findByAddressContaining("서울")).thenReturn(List.of(user)); + when(userRepository.findByEmailVerifiedTrue()).thenReturn(List.of(user)); + when(userRepository.findByUpdatedAtAfter(any())).thenReturn(List.of(user)); + when(userRepository.findByRoleAndDeletedAtIsNull("PARENT")).thenReturn(List.of(user)); + when(userRepository.findByAddressContainingAndDeletedAtIsNull("서울")).thenReturn(List.of(user)); + when(userRepository.findEmailVerifiedUsersNotDeleted()).thenReturn(List.of(user)); + + assertThat(userService.searchUsers("me")).hasSize(1); + assertThat(userService.getActiveUsers()).hasSize(1); + assertThat(userService.getUsersByType("PARENT")).hasSize(1); + assertThat(userService.getUsersByRegion("서울")).hasSize(1); + assertThat(userService.getVerifiedUsers()).hasSize(1); + assertThat(userService.getRecentlyActiveUsers()).hasSize(1); + assertThat(userService.getUsersByRole("PARENT")).hasSize(1); + assertThat(userService.getUsersByLocation("서울")).hasSize(1); + assertThat(userService.getEmailVerifiedUsers()).hasSize(1); + } + + @Test + @DisplayName("통계는 저장소의 집계를 그대로 담는다") + void statistics() { + when(userRepository.count()).thenReturn(10L); + when(userRepository.countActiveUsersNotDeleted()).thenReturn(8L); + when(userRepository.countEmailVerifiedUsersNotDeleted()).thenReturn(6L); + when(userRepository.countNewUsersSince(any())).thenReturn(2L); + + UserStatsResponse stats = userService.getUserStatistics(); + + assertThat(stats.getTotalUsers()).isEqualTo(10L); + assertThat(stats.getActiveUsers()).isEqualTo(8L); + assertThat(stats.getVerifiedUsers()).isEqualTo(6L); + assertThat(stats.getNewUsersToday()).isEqualTo(2L); + } + } + + // ===================================================================== + + @Nested + @DisplayName("DTO 변환") + class Conversion { + + @Test + @DisplayName("응답에 비밀번호 해시가 실리지 않는다") + void doesNotExposePasswordHash() { + // UserDto.password 는 WRITE_ONLY 다. 변환 단계에서도 아예 채우지 않는다. + assertThat(userService.convertToDto(user).getPassword()).isNull(); + } + + @Test + @DisplayName("성별·역할을 문자열로 옮긴다") + void mapsEnums() { + UserDto dto = userService.convertToDto(user); + assertThat(dto.getGender()).isEqualTo("FEMALE"); + assertThat(dto.getRole()).isEqualTo("PARENT"); + } + + @Test + @DisplayName("성별이 없으면 null 이다") + void nullGender() { + user.setGender(null); + assertThat(userService.convertToDto(user).getGender()).isNull(); + } + } + + // ===================================================================== + + @Nested + @DisplayName("카카오 사용자 정보 조회") + class KakaoUserInfo { + + @SuppressWarnings("unchecked") + private void kakaoReturns(Map body) { + when(restTemplate.exchange(eq("https://kapi.kakao.com/v2/user/me"), eq(HttpMethod.GET), + any(HttpEntity.class), any(ParameterizedTypeReference.class))) + .thenReturn(ResponseEntity.ok(body)); + } + + @Test + @DisplayName("id 와 프로필을 뽑아낸다") + void extractsProfile() { + kakaoReturns(Map.of( + "id", 12345L, + "kakao_account", Map.of("profile", Map.of( + "nickname", "맘편한", "profile_image_url", "https://k.kakao.com/p.png")))); + + Map info = userService.getKakaoUserInfo("kakao-access-token"); + + assertThat(info).containsEntry("id", 12345L) + .containsEntry("nickname", "맘편한") + .containsEntry("profileImageUrl", "https://k.kakao.com/p.png"); + } + + @Test + @DisplayName("id 가 없으면 실패한다") + void requiresId() { + kakaoReturns(Map.of("kakao_account", Map.of())); + + assertThatThrownBy(() -> userService.getKakaoUserInfo("t")).isInstanceOf(RuntimeException.class); + } + + @Test + @DisplayName("카카오가 401 을 주면 토큰 문제로 알린다") + void unauthorizedToken() { + when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), + any(ParameterizedTypeReference.class))) + .thenThrow(new HttpClientErrorException(HttpStatus.UNAUTHORIZED)); + + assertThatThrownBy(() -> userService.getKakaoUserInfo("expired")) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("유효하지 않습니다"); + } + } +} diff --git a/src/test/java/com/carecode/integration/EndpointAuthorizationCoverageTest.java b/src/test/java/com/carecode/integration/EndpointAuthorizationCoverageTest.java index c5a38d51..ae41614a 100644 --- a/src/test/java/com/carecode/integration/EndpointAuthorizationCoverageTest.java +++ b/src/test/java/com/carecode/integration/EndpointAuthorizationCoverageTest.java @@ -96,7 +96,9 @@ class EndpointAuthorizationCoverageTest { "POST /auth/login", "POST /auth/register", "POST /auth/refresh", "POST /auth/send-code", "POST /auth/verify-code", "GET /auth/verify", "POST /auth/kakao/login", "GET /auth/kakao/login-url", - "POST /auth/kakao/complete-registration", + // /auth/kakao/complete-registration 은 공개가 아니다. 카카오 로그인이 준 토큰으로 + // 자기 계정의 가입을 끝내는 동작이다. 예전에 여기 공개로 적혀 있었고, 그래서 + // JWT 필터가 건너뛰어 흐름 전체가 401 이었다. // 둘러보기 — 로그인 전에 보여야 가입 전환이 생긴다 "GET /facilities", "GET /facilities/popular", "GET /facilities/new", diff --git a/src/test/java/com/carecode/integration/KakaoRegistrationCompletionTest.java b/src/test/java/com/carecode/integration/KakaoRegistrationCompletionTest.java new file mode 100644 index 00000000..d2c3027f --- /dev/null +++ b/src/test/java/com/carecode/integration/KakaoRegistrationCompletionTest.java @@ -0,0 +1,173 @@ +package com.carecode.integration; + +import com.carecode.CareCodeApplication; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.entity.UserRole; +import com.carecode.domain.user.repository.UserRepository; +import com.carecode.domain.user.service.JwtService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.http.MediaType; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +/** + * 카카오 신규 가입 완료 흐름. + * + *

여기에는 결함이 두 개 겹쳐 있었고, 하나만 고치면 더 나빠지는 구조였다. + * + *

    + *
  1. 흐름이 동작하지 않았다. 카카오 로그인은 가입 미완료 사용자에게도 토큰을 주고, + * 프런트는 그 토큰으로 이 엔드포인트를 부른다. 그런데 JWT 필터가 {@code /auth/kakao} + * 로 시작하는 경로를 통째로 건너뛰어 토큰이 해석되지 않았고, 컨트롤러가 현재 사용자를 + * 찾다가 401 을 냈다. 즉 카카오로 처음 들어온 사람은 누구도 가입을 끝낼 수 없었다.
  2. + *
  3. 역할을 클라이언트가 정했다. 요청 본문의 {@code role} 을 그대로 저장해서, + * {@code "ADMIN"} 을 보내면 그 자리에서 관리자가 된다. 이메일 가입 경로(#82)와 같은 결함이다.
  4. + *
+ * + *

1번만 고치면 2번이 그대로 열린다. 지금까지 2번이 드러나지 않은 건 1번 덕분이었다. + */ +@SpringBootTest( + classes = CareCodeApplication.class, + properties = { + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration," + + "org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration," + + "org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration", + "spring.cache.type=none", + "spring.batch.job.enabled=false", + "spring.datasource.url=jdbc:h2:mem:carecode_kakao;MODE=MySQL;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.flyway.enabled=false", + "jwt.secret=testJwtSecretKeyForKakaoRegistrationMustBe256BitsLong0123456789", + "springdoc.api-docs.enabled=false", + "springdoc.swagger-ui.enabled=false", + "public.data.api.key=dummy", + "KAKAO_CLIENT_ID=dummy-kakao-client", + "KAKAO_CLIENT_SECRET=dummy-kakao-secret", + "MAIL_USERNAME=dummy", + "MAIL_PASSWORD=dummy" + } +) +@AutoConfigureMockMvc +@DisplayName("카카오 가입 완료") +class KakaoRegistrationCompletionTest { + + private static final String EMAIL = "kakao-newbie@example.com"; + + @MockBean RedisConnectionFactory redisConnectionFactory; + @MockBean StringRedisTemplate stringRedisTemplate; + @MockBean JavaMailSender javaMailSender; + + @Autowired MockMvc mockMvc; + @Autowired JwtService jwtService; + @Autowired UserRepository userRepository; + + private String accessToken; + + @BeforeEach + void setUp() { + userRepository.findByEmail(EMAIL).ifPresent(userRepository::delete); + + // 카카오 로그인 직후의 상태: 계정은 있지만 가입 절차를 끝내지 않았다. + User user = userRepository.save(User.builder() + .email(EMAIL) + .name("카카오_12345") + .role(UserRole.PARENT) + .provider("kakao") + .providerId("12345") + .isActive(true) + .emailVerified(true) + .registrationCompleted(false) + .createdAt(LocalDateTime.now()) + .build()); + + accessToken = jwtService.generateAccessToken(user.getUserId(), EMAIL, "PARENT", user.getName()); + } + + private MvcResult complete(String role) throws Exception { + return mockMvc.perform(post("/auth/kakao/complete-registration") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"맘편한\",\"role\":\"" + role + "\"}")) + .andReturn(); + } + + @Test + @DisplayName("카카오 로그인으로 받은 토큰으로 가입을 끝낼 수 있다") + void newKakaoUserCanCompleteRegistration() throws Exception { + MvcResult result = complete("PARENT"); + + assertThat(result.getResponse().getStatus()) + .as("JWT 필터가 이 경로를 건너뛰면 토큰이 해석되지 않아 401 이 된다") + .isEqualTo(200); + + User saved = userRepository.findByEmail(EMAIL).orElseThrow(); + assertThat(saved.getRegistrationCompleted()).isTrue(); + assertThat(saved.getName()).isEqualTo("맘편한"); + } + + @Test + @DisplayName("보육사로도 가입할 수 있다") + void caregiverIsSelfSelectable() throws Exception { + assertThat(complete("CAREGIVER").getResponse().getStatus()).isEqualTo(200); + assertThat(userRepository.findByEmail(EMAIL).orElseThrow().getRole()).isEqualTo(UserRole.CAREGIVER); + } + + @ParameterizedTest(name = "role={0} 으로는 가입을 끝낼 수 없다") + @ValueSource(strings = {"ADMIN", "GUEST"}) + @DisplayName("스스로 고를 수 없는 역할은 거부한다") + void privilegedRoleIsRejected(String role) throws Exception { + MvcResult result = complete(role); + + assertThat(result.getResponse().getStatus()).isEqualTo(400); + + User saved = userRepository.findByEmail(EMAIL).orElseThrow(); + assertThat(saved.getRole()) + .as("요청의 role 이 그대로 저장되면 그 자리에서 관리자가 된다") + .isEqualTo(UserRole.PARENT); + assertThat(saved.getRegistrationCompleted()).isFalse(); + } + + @Test + @DisplayName("토큰 없이는 가입을 끝낼 수 없다") + void requiresAuthentication() throws Exception { + MvcResult result = mockMvc.perform(post("/auth/kakao/complete-registration") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"맘편한\",\"role\":\"PARENT\"}")) + .andReturn(); + + assertThat(result.getResponse().getStatus()).isEqualTo(401); + } + + @Test + @DisplayName("카카오 로그인 자체는 여전히 토큰 없이 호출된다") + void kakaoLoginStaysPublic() throws Exception { + // 필터 예외를 좁히면서 로그인까지 막으면 안 된다. + MvcResult result = mockMvc.perform(post("/auth/kakao/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andReturn(); + + assertThat(result.getResponse().getStatus()).isNotIn(401, 403); + } +}