From b70a9c876c00a133a477d3b4b892c82ff2a5b497 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Tue, 22 Sep 2026 15:32:04 +0900 Subject: [PATCH] =?UTF-8?q?FIX=20:=20=EA=B0=80=EC=9E=85=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20=EC=9A=94=EC=B2=AD=20DTO=EC=99=80=20=ED=81=B4?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EC=98=A4=EB=A5=98=204xx?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20(#91,=20#107)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) 가입 요청을 SignUpRequest 로 분리했다 (#91 의 첫 항목). POST /auth/register 가 응답 DTO 인 UserDto 를 그대로 받았다. 서버가 정할 값(role, provider, emailVerified …)이 요청 계약에 노출돼 있었고(#82), UserDto 에 검증 어노테이션이 없어 @Valid 가 아무 일도 하지 않았다. 빈 이메일·형식이 틀린 이메일· 한 글자 비밀번호로 가입됐고, 이름이 없으면 DB 제약에서, 성별 값이 틀리면 Gender.valueOf 에서 500 이었다. 가입자가 정할 수 있는 값만 두고 검증을 붙였다. 요청에 role 등을 넣어도 받을 필드가 없어 버려진다. 서비스는 서버 결정 값을 여전히 명시적으로 고정해, 요청 타입이 다시 넓어져도 결과가 바뀌지 않게 했다. 프런트 화면은 이 엔드포인트를 호출하지 않는다. 2) 잘못 보낸 요청이 500 + 운영 알림이 되던 것을 4xx 로. 이 핸들러는 ResponseEntityExceptionHandler 를 상속하지 않고 Exception 최후 핸들러를 둔다. 그래서 스프링이 원래 4xx 로 바꿔주던 예외까지 전부 500 이 되고 Slack 알림이 울렸다. 실측으로 확인했다(수정 전 5건 전부 500). 깨진 JSON / 필수 파라미터 누락 / 타입 불일치 → 400 지원하지 않는 메서드 → 405 지원하지 않는 Content-Type → 415 업로드 크기 초과 → 413 응답 메시지에 예외 원문은 넣지 않는다(역직렬화 오류에 내부 클래스명이 들어 있다). 3) @RateLimit AOP 가 모든 런타임 실패에 fail-open 하도록. DataAccessException 만 잡아서, 그 밖의 실패는 500 이 되고 로그인·가입·인증코드·챗봇이 한꺼번에 멈췄다. RateLimitInterceptor 에서 같은 결함을 고쳤는데(#87) 여기는 남아 있었다. 회원가입 통합 테스트가 이것 때문에 500 으로 떨어져 드러났다. 대상 메서드가 던진 예외는 삼키지 않는다(테스트로 고정). #91 의 두 번째 항목(/users 구 경로 제거)은 이 PR 에 없다. 운영 백엔드가 배포되지 않아 /users/me/* 가 운영에 없는 상태에서 프런트를 먼저 바꾸면 위치 갱신이 깨진다. 순서(백엔드 배포 → 프런트 전환 → 구 경로 제거)를 #91 에 남긴다. 486 tests, 0 failures. 전체 라인 커버리지 39.1%. --- .../core/aspect/RateLimitingAspect.java | 10 +- ...tomizedResponseEntityExceptionHandler.java | 66 ++++++++ .../user/controller/AuthController.java | 3 +- .../user/dto/request/SignUpRequest.java | 67 ++++++++ .../domain/user/service/UserService.java | 53 +++---- .../core/aspect/RateLimitingAspectTest.java | 150 ++++++++++++++++++ .../user/service/UserServiceSignUpTest.java | 105 ++++++------ .../integration/ClientErrorStatusTest.java | 117 ++++++++++++++ .../integration/SignUpContractTest.java | 120 ++++++++++++++ 9 files changed, 604 insertions(+), 87 deletions(-) create mode 100644 src/main/java/com/carecode/domain/user/dto/request/SignUpRequest.java create mode 100644 src/test/java/com/carecode/core/aspect/RateLimitingAspectTest.java create mode 100644 src/test/java/com/carecode/integration/ClientErrorStatusTest.java create mode 100644 src/test/java/com/carecode/integration/SignUpContractTest.java diff --git a/src/main/java/com/carecode/core/aspect/RateLimitingAspect.java b/src/main/java/com/carecode/core/aspect/RateLimitingAspect.java index 9a473be7..9cd56bfc 100644 --- a/src/main/java/com/carecode/core/aspect/RateLimitingAspect.java +++ b/src/main/java/com/carecode/core/aspect/RateLimitingAspect.java @@ -9,7 +9,6 @@ import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; -import org.springframework.dao.DataAccessException; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.core.Authentication; @@ -43,9 +42,14 @@ public Object rateLimit(ProceedingJoinPoint joinPoint, RateLimit rateLimit) thro if (count == 1L) { redisTemplate.expire(key, Duration.ofSeconds(rateLimit.windowSeconds())); } - } catch (DataAccessException e) { + } catch (RuntimeException e) { // Redis 장애로 전체 API 가 막히지 않도록 fail-open 한다. - log.error("Rate limit 카운터 조회 실패 - Redis 장애로 제한을 건너뜁니다. key={}", key, e); + // + // 예전에는 DataAccessException 만 잡았다. 그 밖의 실패(연결 팩토리 상태에 따라 + // opsForValue() 가 null 이거나, 풀·직렬화 단계에서 나는 오류)는 그대로 500 이 됐고, + // 이 어노테이션이 붙은 로그인·가입·인증코드·챗봇이 한꺼번에 멈췄다. + // RateLimitInterceptor 에서 같은 결함을 고쳤는데(#87) 여기는 남아 있었다. + log.error("Rate limit 카운터 조회 실패 - 제한을 건너뜁니다. key={}", key, e); return joinPoint.proceed(); } diff --git a/src/main/java/com/carecode/core/handler/CustomizedResponseEntityExceptionHandler.java b/src/main/java/com/carecode/core/handler/CustomizedResponseEntityExceptionHandler.java index e8cafa44..106a386d 100644 --- a/src/main/java/com/carecode/core/handler/CustomizedResponseEntityExceptionHandler.java +++ b/src/main/java/com/carecode/core/handler/CustomizedResponseEntityExceptionHandler.java @@ -264,6 +264,72 @@ public ResponseEntity handleNoResourceFound(NoResourceFoundExcept .body(errorResponse); } + /** + * 클라이언트가 잘못 보낸 요청. + * + *

이 클래스는 ResponseEntityExceptionHandler 를 상속하지 않고 아래에 Exception 최후 핸들러를 + * 두고 있다. 그래서 스프링이 원래 4xx 로 바꿔주던 예외가 전부 최후 핸들러에 잡혀 + * 500 + Slack 운영 알림이 됐다. 실측으로 확인했다(ClientErrorStatusTest, 수정 전 5건 전부 500). + * 깨진 JSON 하나, 파라미터 하나 빠진 요청마다 알림이 울리면 진짜 장애가 그 소음에 묻힌다. + * 없는 URL(404)·권한 거부(403)를 걸러낸 것과 같은 이유다. + * + *

메시지에 예외 원문을 넣지 않는다. 역직렬화 오류 메시지에는 내부 클래스명과 + * 필드 구조가 그대로 들어 있다. + */ + @ExceptionHandler({ + org.springframework.http.converter.HttpMessageNotReadableException.class, + org.springframework.web.bind.MissingServletRequestParameterException.class, + org.springframework.web.method.annotation.MethodArgumentTypeMismatchException.class, + org.springframework.web.multipart.support.MissingServletRequestPartException.class, + org.springframework.web.bind.MissingPathVariableException.class + }) + public ResponseEntity handleBadRequest(Exception ex, WebRequest request) { + log.warn("잘못된 요청: {} - {}", ex.getClass().getSimpleName(), request.getDescription(false)); + + // Java 17 이라 타입 패턴 switch 대신 instanceof 로 가른다. + String message = "잘못된 요청입니다"; + if (ex instanceof org.springframework.http.converter.HttpMessageNotReadableException) { + message = "요청 본문을 읽을 수 없습니다"; + } else if (ex instanceof org.springframework.web.bind.MissingServletRequestParameterException e) { + message = "필수 파라미터가 없습니다: " + e.getParameterName(); + } else if (ex instanceof org.springframework.web.method.annotation.MethodArgumentTypeMismatchException e) { + message = "파라미터 형식이 올바르지 않습니다: " + e.getName(); + } else if (ex instanceof org.springframework.web.multipart.support.MissingServletRequestPartException e) { + message = "필수 파일이 없습니다: " + e.getRequestPartName(); + } + + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse.of(ErrorCode.INVALID_INPUT, message, request.getDescription(false))); + } + + @ExceptionHandler(org.springframework.web.HttpRequestMethodNotSupportedException.class) + public ResponseEntity handleMethodNotSupported( + org.springframework.web.HttpRequestMethodNotSupportedException ex, WebRequest request) { + log.warn("지원하지 않는 메서드: {} {}", ex.getMethod(), request.getDescription(false)); + return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED) + .body(ErrorResponse.of(ErrorCode.INVALID_INPUT, + "지원하지 않는 메서드입니다: " + ex.getMethod(), request.getDescription(false))); + } + + @ExceptionHandler(org.springframework.web.HttpMediaTypeNotSupportedException.class) + public ResponseEntity handleMediaTypeNotSupported( + org.springframework.web.HttpMediaTypeNotSupportedException ex, WebRequest request) { + log.warn("지원하지 않는 Content-Type: {} {}", ex.getContentType(), request.getDescription(false)); + return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + .body(ErrorResponse.of(ErrorCode.INVALID_INPUT, + "지원하지 않는 Content-Type 입니다", request.getDescription(false))); + } + + /** 업로드 크기 초과. spring.servlet.multipart.max-file-size(10MB) 를 넘으면 여기로 온다. */ + @ExceptionHandler(org.springframework.web.multipart.MaxUploadSizeExceededException.class) + public ResponseEntity handleMaxUploadSize( + org.springframework.web.multipart.MaxUploadSizeExceededException ex, WebRequest request) { + log.warn("업로드 크기 초과: {}", request.getDescription(false)); + return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE) + .body(ErrorResponse.of(ErrorCode.INVALID_INPUT, + "파일이 너무 큽니다", request.getDescription(false))); + } + // 모든 예외 처리 (최후의 수단) @ExceptionHandler(Exception.class) public ResponseEntity handleAllExceptions(Exception ex, WebRequest request) { diff --git a/src/main/java/com/carecode/domain/user/controller/AuthController.java b/src/main/java/com/carecode/domain/user/controller/AuthController.java index b7fbcccf..01ca586d 100644 --- a/src/main/java/com/carecode/domain/user/controller/AuthController.java +++ b/src/main/java/com/carecode/domain/user/controller/AuthController.java @@ -8,6 +8,7 @@ import com.carecode.core.exception.UserNotFoundException; import com.carecode.domain.user.dto.request.LoginRequestDto; import com.carecode.domain.user.dto.request.RefreshTokenRequest; +import com.carecode.domain.user.dto.request.SignUpRequest; import com.carecode.domain.user.dto.response.TokenDto; import com.carecode.domain.user.dto.response.UserDto; import com.carecode.domain.user.entity.User; @@ -84,7 +85,7 @@ public ResponseEntity login(@Parameter(description = "로그인 정보 @LogExecutionTime @RateLimit(requests = 5, windowSeconds = 3600, message = "가입 요청이 너무 많습니다. 잠시 후 다시 시도해주세요.") @Operation(summary = "회원가입", description = "새로운 사용자 등록") - public ResponseEntity register(@Parameter(description = "회원가입 정보", required = true) @Valid @RequestBody UserDto request) { + public ResponseEntity register(@Parameter(description = "회원가입 정보", required = true) @Valid @RequestBody SignUpRequest request) { UserDto createdUser = userService.createUser(request); User user = userService.getUserEntityByEmail(createdUser.getEmail()); TokenDto tokenDto = authService.issueTokenForUser(user, "회원가입 성공!"); diff --git a/src/main/java/com/carecode/domain/user/dto/request/SignUpRequest.java b/src/main/java/com/carecode/domain/user/dto/request/SignUpRequest.java new file mode 100644 index 00000000..4d91b8d9 --- /dev/null +++ b/src/main/java/com/carecode/domain/user/dto/request/SignUpRequest.java @@ -0,0 +1,67 @@ +package com.carecode.domain.user.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Past; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDate; + +/** + * 이메일 회원가입 요청 DTO. + * + *

예전에는 응답 DTO 인 {@code UserDto} 를 그대로 요청 본문으로 받았다. 그래서 + * {@code role} / {@code provider} / {@code providerId} / {@code isActive} / {@code emailVerified} 처럼 + * 서버가 정해야 할 필드가 전부 요청 계약에 노출돼 있었고, 실제로 {@code role} 을 그대로 + * 저장해 누구나 관리자가 될 수 있었다(#82). 서비스에서 값을 무시하도록 막았지만, Swagger 에는 + * 여전히 설정 가능한 것처럼 보였고 "무시되니까 괜찮다" 는 전제는 언젠가 깨진다. + * + *

여기에는 가입자가 정할 수 있는 값만 둔다. 요청에 {@code role} 등을 넣어 보내도 + * 받을 필드가 없어 버려진다(스프링 기본값: 모르는 필드 무시). + * + *

또 {@code UserDto} 에는 검증 어노테이션이 하나도 없어서 {@code @Valid} 가 아무 일도 하지 않았다. + * 빈 이메일·형식이 틀린 이메일·한 글자 비밀번호로도 가입됐고, 이름이 없으면 DB NOT NULL 제약에서 500 이 났다. + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SignUpRequest { + + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "이메일 형식이 올바르지 않습니다") + @Size(max = 255, message = "이메일이 너무 깁니다") + private String email; + + /** + * 상한을 두는 건 BCrypt 가 72바이트 이후를 무시하기 때문이다(그 뒤는 검사되지 않는다). + * 영문 기준 64자면 안쪽이다. 한글은 글자당 3바이트라 24자를 넘으면 뒷부분이 잘린다. + */ + @NotBlank(message = "비밀번호는 필수입니다") + @Size(min = 8, max = 64, message = "비밀번호는 8~64자여야 합니다") + private String password; + + @NotBlank(message = "이름은 필수입니다") + @Size(max = 50, message = "이름은 50자를 넘을 수 없습니다") + private String name; + + @Pattern(regexp = "^[0-9+\\-() ]{0,20}$", message = "전화번호 형식이 올바르지 않습니다") + private String phoneNumber; + + @Past(message = "생년월일은 과거 날짜여야 합니다") + private LocalDate birthDate; + + /** Gender.valueOf 에 그대로 넘기므로 enum 이름만 받는다. 다른 값이면 예전에는 500 이었다. */ + @Pattern(regexp = "^(MALE|FEMALE|OTHER)$", message = "성별은 MALE, FEMALE, OTHER 중 하나여야 합니다") + private String gender; + + @Size(max = 255, message = "주소가 너무 깁니다") + private String address; +} 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 8d4b9ca4..a601149e 100644 --- a/src/main/java/com/carecode/domain/user/service/UserService.java +++ b/src/main/java/com/carecode/domain/user/service/UserService.java @@ -6,6 +6,7 @@ import com.carecode.core.annotation.RequireAuthentication; import com.carecode.core.exception.UserNotFoundException; import com.carecode.domain.user.dto.request.PasswordChangeRequestDto; +import com.carecode.domain.user.dto.request.SignUpRequest; import com.carecode.domain.user.dto.response.UserDto; import com.carecode.domain.user.dto.response.UserStatsResponse; import com.carecode.domain.user.entity.User; @@ -245,46 +246,38 @@ public UserDto completeKakaoRegistration(String email, String name, String role) return convertToDto(updatedUser); } - // 사용자 생성 + /** + * 이메일 회원가입. + * + *

요청은 {@link SignUpRequest} 다. 예전에는 응답 DTO 인 UserDto 를 그대로 받아서 + * role/provider/emailVerified 같은 서버 결정 값이 요청 계약에 노출돼 있었고, 실제로 role 을 + * 그대로 저장해 누구나 관리자가 될 수 있었다(#82). 이제 요청 타입에 그 필드가 아예 없다. + * 그래도 아래에서 값을 명시적으로 고정해, 요청 타입이 다시 넓어져도 결과가 바뀌지 않게 한다. + */ @Transactional - public UserDto createUser(UserDto userDto) { - log.info("사용자 생성: 이메일={}, provider={}", userDto.getEmail(), userDto.getProvider()); + public UserDto createUser(SignUpRequest request) { + log.info("사용자 생성: 이메일={}", request.getEmail()); // 이메일 중복 확인 - if (userRepository.findByEmail(userDto.getEmail()).isPresent()) { - throw new IllegalArgumentException("이미 존재하는 이메일입니다: " + userDto.getEmail()); + if (userRepository.findByEmail(request.getEmail()).isPresent()) { + throw new IllegalArgumentException("이미 존재하는 이메일입니다: " + request.getEmail()); } - // 이메일 회원가입이므로 비밀번호는 항상 필수다. - if (userDto.getPassword() == null || userDto.getPassword().trim().isEmpty()) { + // 컨트롤러의 @Valid 가 먼저 막지만, 서비스를 직접 부르는 경로에서도 비밀번호 없는 계정이 생기면 안 된다. + if (request.getPassword() == null || request.getPassword().trim().isEmpty()) { throw new IllegalArgumentException("비밀번호는 필수입니다."); } - String encodedPassword = passwordEncoder.encode(userDto.getPassword()); - - // 클라이언트가 보낸 role 은 신뢰하지 않는다. - // - // 예전에는 요청 본문의 role 을 그대로 썼다. 이 엔드포인트(POST /auth/register)는 - // permitAll 이므로, 로그인조차 없이 {"role":"ADMIN"} 으로 가입하면 그 자리에서 - // 관리자가 됐다. 가입은 언제나 일반 사용자로 끝나야 하고, 승격은 관리자만 할 수 있는 - // 별도 경로(PUT /api/admin/users/{id}/role)로만 가능해야 한다. - if (userDto.getRole() != null && !SELF_SIGNUP_ROLE.name().equals(userDto.getRole())) { - log.warn("회원가입 요청의 role 을 무시합니다 - 요청값={}, 적용값={}", - userDto.getRole(), SELF_SIGNUP_ROLE); - } + String encodedPassword = passwordEncoder.encode(request.getPassword()); - // provider/providerId 도 마찬가지다. 소셜 가입은 AuthServiceImpl 의 별도 경로가 처리하며 - // 그쪽에서 provider 를 직접 지정한다. 여기서 클라이언트가 provider 를 붙일 수 있게 두면 - // 비밀번호 없이(로그인 불가하지만) 임의 이메일·임의 providerId 로 계정을 미리 만들어 둘 수 있고, - // 이메일 인증도 건너뛴 것으로 표시됐다. User user = User.builder() - .email(userDto.getEmail()) + .email(request.getEmail()) .password(encodedPassword) - .name(userDto.getName()) - .phoneNumber(userDto.getPhoneNumber()) - .birthDate(userDto.getBirthDate()) - .gender(userDto.getGender() != null ? Gender.valueOf(userDto.getGender()) : null) - .address(userDto.getAddress()) - .profileImageUrl(userDto.getProfileImageUrl()) + .name(request.getName()) + .phoneNumber(request.getPhoneNumber()) + .birthDate(request.getBirthDate()) + .gender(request.getGender() != null ? Gender.valueOf(request.getGender()) : null) + .address(request.getAddress()) + // 서버가 정하는 값. 요청으로는 바꿀 수 없다. .role(SELF_SIGNUP_ROLE) .provider(null) .providerId(null) diff --git a/src/test/java/com/carecode/core/aspect/RateLimitingAspectTest.java b/src/test/java/com/carecode/core/aspect/RateLimitingAspectTest.java new file mode 100644 index 00000000..5501cac6 --- /dev/null +++ b/src/test/java/com/carecode/core/aspect/RateLimitingAspectTest.java @@ -0,0 +1,150 @@ +package com.carecode.core.aspect; + +import com.carecode.core.annotation.RateLimit; +import com.carecode.core.exception.RateLimitExceededException; +import com.carecode.core.util.ClientIpResolver; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.Signature; +import org.junit.jupiter.api.AfterEach; +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.data.redis.RedisConnectionFailureException; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.lang.annotation.Annotation; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.startsWith; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@code @RateLimit} 을 처리하는 AOP 의 계약. + * + *

로그인·가입·인증코드 발송/검증·챗봇이 이 어노테이션에 기대고 있다. + *

+ */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("RateLimitingAspect") +class RateLimitingAspectTest { + + @Mock private StringRedisTemplate redisTemplate; + @Mock private ValueOperations valueOperations; + @Mock private ProceedingJoinPoint joinPoint; + @Mock private Signature signature; + + private RateLimitingAspect aspect; + + private static RateLimit limit(int requests) { + return new RateLimit() { + @Override public Class annotationType() { return RateLimit.class; } + @Override public int requests() { return requests; } + @Override public int windowSeconds() { return 60; } + @Override public String message() { return "too many"; } + @Override public boolean perUser() { return true; } + }; + } + + @BeforeEach + void setUp() throws Throwable { + aspect = new RateLimitingAspect(redisTemplate, new ClientIpResolver(false)); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(joinPoint.getSignature()).thenReturn(signature); + when(signature.toShortString()).thenReturn("AuthController.login(..)"); + when(joinPoint.proceed()).thenReturn("ok"); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("203.0.113.5"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + + @AfterEach + void tearDown() { + RequestContextHolder.resetRequestAttributes(); + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("한도 안이면 통과한다") + void allowsWithinLimit() throws Throwable { + when(valueOperations.increment(anyString())).thenReturn(1L); + + assertThat(aspect.rateLimit(joinPoint, limit(5))).isEqualTo("ok"); + } + + @Test + @DisplayName("한도를 넘으면 막고 대상 메서드를 실행하지 않는다") + void blocksOverLimit() throws Throwable { + when(valueOperations.increment(anyString())).thenReturn(6L); + + assertThatThrownBy(() -> aspect.rateLimit(joinPoint, limit(5))) + .isInstanceOf(RateLimitExceededException.class); + verify(joinPoint, never()).proceed(); + } + + @Test + @DisplayName("Redis 연결이 끊겨도 통과한다") + void failsOpenOnRedisOutage() throws Throwable { + when(valueOperations.increment(anyString())).thenThrow(new RedisConnectionFailureException("down")); + + assertThat(aspect.rateLimit(joinPoint, limit(5))).isEqualTo("ok"); + } + + @Test + @DisplayName("DataAccessException 이 아닌 실패에도 통과한다") + void failsOpenOnAnyRuntimeFailure() throws Throwable { + // 예전에는 여기서 NPE 가 그대로 올라가 500 이었다. + when(redisTemplate.opsForValue()).thenReturn(null); + + assertThat(aspect.rateLimit(joinPoint, limit(5))).isEqualTo("ok"); + } + + @Test + @DisplayName("대상 메서드가 던진 예외는 삼키지 않는다") + void doesNotSwallowTargetExceptions() throws Throwable { + // fail-open 범위를 넓히면서 대상 메서드의 예외까지 먹으면 안 된다. + when(valueOperations.increment(anyString())).thenReturn(1L); + when(joinPoint.proceed()).thenThrow(new IllegalArgumentException("비즈니스 오류")); + + assertThatThrownBy(() -> aspect.rateLimit(joinPoint, limit(5))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("비즈니스 오류"); + } + + @Test + @DisplayName("비로그인은 IP 기준, 로그인은 계정 기준으로 센다") + void keysByIpOrPrincipal() throws Throwable { + when(valueOperations.increment(anyString())).thenReturn(1L); + + aspect.rateLimit(joinPoint, limit(5)); + verify(valueOperations).increment(eq("ratelimit:method:AuthController.login(..):ip:203.0.113.5")); + + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( + "me@example.com", null, List.of(new SimpleGrantedAuthority("ROLE_PARENT")))); + aspect.rateLimit(joinPoint, limit(5)); + verify(valueOperations).increment(startsWith("ratelimit:method:AuthController.login(..):user:me@example.com")); + } +} diff --git a/src/test/java/com/carecode/domain/user/service/UserServiceSignUpTest.java b/src/test/java/com/carecode/domain/user/service/UserServiceSignUpTest.java index 136a64de..4990d4a7 100644 --- a/src/test/java/com/carecode/domain/user/service/UserServiceSignUpTest.java +++ b/src/test/java/com/carecode/domain/user/service/UserServiceSignUpTest.java @@ -1,7 +1,9 @@ package com.carecode.domain.user.service; import com.carecode.core.analytics.EventLogger; +import com.carecode.domain.user.dto.request.SignUpRequest; import com.carecode.domain.user.dto.response.UserDto; +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; @@ -9,8 +11,6 @@ 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.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -20,6 +20,7 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.client.RestTemplate; +import java.time.LocalDate; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -33,11 +34,12 @@ /** * 회원가입에서 서버가 정해야 하는 값에 대한 회귀 테스트. * - *

{@code POST /auth/register} 는 permitAll 이다. 그런데 예전 구현은 요청 본문의 - * {@code role} 을 그대로 엔티티에 넣었다. 즉 로그인조차 없이 - * {@code {"role":"ADMIN"}} 으로 가입하면 그 자리에서 관리자가 됐다. - * {@code provider} 도 클라이언트가 붙일 수 있어, 비밀번호 없이 임의 이메일·임의 providerId 로 - * 계정을 미리 만들면서 이메일 인증까지 통과한 것으로 표시할 수 있었다. + *

{@code POST /auth/register} 는 permitAll 이다. 예전 구현은 응답 DTO 인 UserDto 를 요청으로 받아 + * 본문의 {@code role} 을 그대로 엔티티에 넣었다. 즉 로그인조차 없이 {@code {"role":"ADMIN"}} 으로 + * 가입하면 관리자가 됐다(#82). 이제 요청 타입({@link SignUpRequest})에 그런 필드가 없다. + * + *

"요청에 role 을 넣어 보내도 무시된다" 는 실제 JSON 을 보내야 확인되므로 + * {@code SignUpContractTest}(통합)에 둔다. 여기서는 서비스가 고정하는 값을 확인한다. */ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @@ -63,8 +65,8 @@ void setUp() { }); } - private UserDto.UserDtoBuilder signupRequest() { - return UserDto.builder() + private SignUpRequest.SignUpRequestBuilder request() { + return SignUpRequest.builder() .email("new@example.com") .password("secret123") .name("신규회원"); @@ -76,32 +78,18 @@ private User captureSaved() { return captor.getValue(); } - @ParameterizedTest(name = "role={0} 으로 가입해도 PARENT 로 저장된다") - @ValueSource(strings = {"ADMIN", "CAREGIVER", "GUEST", "USER"}) - @DisplayName("클라이언트가 보낸 role 은 무시된다") - void clientSuppliedRoleIsIgnored(String requestedRole) { - userService.createUser(signupRequest().role(requestedRole).build()); - - assertThat(captureSaved().getRole()).isEqualTo(UserRole.PARENT); - } - @Test - @DisplayName("role 을 아예 보내지 않아도 가입에 성공한다") - void missingRoleIsFine() { - // 예전에는 UserRole.valueOf(null) 이 터져 500 이 났다. - UserDto created = userService.createUser(signupRequest().build()); + @DisplayName("역할은 언제나 PARENT 로 저장된다") + void roleIsAlwaysParent() { + userService.createUser(request().build()); - assertThat(created).isNotNull(); assertThat(captureSaved().getRole()).isEqualTo(UserRole.PARENT); } @Test - @DisplayName("클라이언트가 provider 를 붙여도 소셜 계정으로 만들어지지 않는다") - void clientSuppliedProviderIsIgnored() { - userService.createUser(signupRequest() - .provider("kakao") - .providerId("999999") - .build()); + @DisplayName("소셜 계정으로 만들어지지 않는다") + void notASocialAccount() { + userService.createUser(request().build()); User saved = captureSaved(); assertThat(saved.getProvider()).isNull(); @@ -109,50 +97,61 @@ void clientSuppliedProviderIsIgnored() { } @Test - @DisplayName("이메일 인증 여부는 항상 false 로 시작한다") - void emailVerifiedStartsFalse() { - userService.createUser(signupRequest() - .provider("kakao") // 예전에는 이것만으로 인증 완료 처리됐다 - .emailVerified(true) - .build()); - - assertThat(captureSaved().getEmailVerified()).isFalse(); - } - - @Test - @DisplayName("provider 를 붙여 비밀번호를 건너뛸 수 없다") - void passwordIsAlwaysRequired() { - assertThatThrownBy(() -> userService.createUser( - UserDto.builder() - .email("new@example.com") - .name("신규회원") - .provider("kakao") // 예전에는 이 경우 비밀번호 검사를 건너뛰었다 - .build())) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("비밀번호"); + @DisplayName("이메일 인증 여부는 false 로 시작하고 계정은 활성이다") + void startsUnverifiedAndActive() { + userService.createUser(request().build()); - verify(userRepository, never()).save(any(User.class)); + User saved = captureSaved(); + assertThat(saved.getEmailVerified()).isFalse(); + assertThat(saved.getIsActive()).isTrue(); } @Test @DisplayName("비밀번호는 해시로 저장된다") void passwordIsHashed() { - userService.createUser(signupRequest().build()); + userService.createUser(request().build()); assertThat(captureSaved().getPassword()) .isEqualTo("$2a$10$encoded") .isNotEqualTo("secret123"); } + @Test + @DisplayName("비밀번호 없이 서비스를 직접 불러도 계정이 생기지 않는다") + void passwordIsRequiredEvenWithoutControllerValidation() { + assertThatThrownBy(() -> userService.createUser(request().password(" ").build())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("비밀번호"); + + verify(userRepository, never()).save(any(User.class)); + } + @Test @DisplayName("이미 가입된 이메일이면 거부한다") void rejectsDuplicateEmail() { when(userRepository.findByEmail("new@example.com")) .thenReturn(Optional.of(User.builder().id(9L).email("new@example.com").build())); - assertThatThrownBy(() -> userService.createUser(signupRequest().build())) + assertThatThrownBy(() -> userService.createUser(request().build())) .isInstanceOf(IllegalArgumentException.class); verify(userRepository, never()).save(any(User.class)); } + + @Test + @DisplayName("선택 항목을 그대로 옮긴다") + void mapsOptionalFields() { + UserDto dto = userService.createUser(request() + .phoneNumber("010-1234-5678") + .birthDate(LocalDate.of(1990, 5, 1)) + .gender("FEMALE") + .address("서울") + .build()); + + User saved = captureSaved(); + assertThat(saved.getGender()).isEqualTo(Gender.FEMALE); + assertThat(saved.getBirthDate()).isEqualTo(LocalDate.of(1990, 5, 1)); + assertThat(saved.getAddress()).isEqualTo("서울"); + assertThat(dto.getEmail()).isEqualTo("new@example.com"); + } } diff --git a/src/test/java/com/carecode/integration/ClientErrorStatusTest.java b/src/test/java/com/carecode/integration/ClientErrorStatusTest.java new file mode 100644 index 00000000..2c6f2332 --- /dev/null +++ b/src/test/java/com/carecode/integration/ClientErrorStatusTest.java @@ -0,0 +1,117 @@ +package com.carecode.integration; + +import com.carecode.CareCodeApplication; +import com.carecode.core.ops.OperationalAlerter; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +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 org.springframework.test.web.servlet.RequestBuilder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +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; + +/** + * 클라이언트가 잘못 보낸 요청은 4xx 로 끝나고 운영 알림을 울리지 않는다. + * + *

전역 핸들러에 {@code @ExceptionHandler(Exception.class)} 가 있고, 이 클래스는 + * {@code ResponseEntityExceptionHandler} 를 상속하지 않는다. 그래서 스프링이 원래 4xx 로 + * 바꿔주던 예외(깨진 JSON, 빠진 파라미터, 타입 불일치, 지원하지 않는 메서드)까지 전부 + * 최후 핸들러가 삼켜 500 + Slack 운영 알림이 됐다. + * + *

운영 문서(operations.md)가 경계하는 바로 그 유형이다 — "봇이 긁고 가면 알림이 울리고, + * 진짜 장애가 그 소음에 묻힌다". 없는 URL(404)과 권한 거부(403)는 예전에 걸러냈는데 + * 이 네 가지가 남아 있었다. + */ +@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_clienterr;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=testJwtSecretKeyForClientErrorStatusMustBe256BitsLong0123456789", + "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("클라이언트 오류는 4xx, 알림 없음") +class ClientErrorStatusTest { + + @MockBean RedisConnectionFactory redisConnectionFactory; + @MockBean StringRedisTemplate stringRedisTemplate; + @MockBean JavaMailSender javaMailSender; + @MockBean OperationalAlerter alerter; + + @Autowired MockMvc mockMvc; + + private int status(RequestBuilder request) throws Exception { + MvcResult result = mockMvc.perform(request).andReturn(); + return result.getResponse().getStatus(); + } + + @Test + @DisplayName("깨진 JSON 은 400") + void malformedJson() throws Exception { + assertThat(status(post("/auth/login").contentType(MediaType.APPLICATION_JSON).content("{not json"))) + .isEqualTo(400); + verify(alerter, never()).alert(anyString(), anyString(), anyString()); + } + + @Test + @DisplayName("필수 파라미터가 빠지면 400") + void missingParameter() throws Exception { + assertThat(status(post("/auth/send-code"))).isEqualTo(400); + verify(alerter, never()).alert(anyString(), anyString(), anyString()); + } + + @Test + @DisplayName("경로 변수 타입이 틀리면 400") + void typeMismatch() throws Exception { + assertThat(status(get("/policies/not-a-number"))).isEqualTo(400); + verify(alerter, never()).alert(anyString(), anyString(), anyString()); + } + + @Test + @DisplayName("지원하지 않는 메서드는 405") + void methodNotAllowed() throws Exception { + assertThat(status(delete("/auth/login"))).isEqualTo(405); + verify(alerter, never()).alert(anyString(), anyString(), anyString()); + } + + @Test + @DisplayName("지원하지 않는 Content-Type 은 415") + void unsupportedMediaType() throws Exception { + assertThat(status(post("/auth/login").contentType(MediaType.TEXT_PLAIN).content("x"))) + .isEqualTo(415); + verify(alerter, never()).alert(anyString(), anyString(), anyString()); + } +} diff --git a/src/test/java/com/carecode/integration/SignUpContractTest.java b/src/test/java/com/carecode/integration/SignUpContractTest.java new file mode 100644 index 00000000..dbbf5701 --- /dev/null +++ b/src/test/java/com/carecode/integration/SignUpContractTest.java @@ -0,0 +1,120 @@ +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 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 static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +/** + * 회원가입 요청 계약 — 실제 JSON 으로 확인한다. + * + *

예전에는 응답 DTO 인 UserDto 를 요청으로 받았다. 두 가지 문제가 있었다. + *

    + *
  • 서버가 정할 값(role, provider, emailVerified …)이 요청 계약에 노출돼 있었다(#82).
  • + *
  • UserDto 에 검증 어노테이션이 없어 {@code @Valid} 가 아무 일도 하지 않았다. + * 빈 이메일·형식이 틀린 이메일·한 글자 비밀번호로 가입됐고, 이름이 없으면 DB 제약에서 500 이었다.
  • + *
+ */ +@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_signup;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=testJwtSecretKeyForSignUpContractMustBe256BitsLong0123456789ab", + "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 SignUpContractTest { + + private static final String EMAIL = "signup-contract@example.com"; + + @MockBean RedisConnectionFactory redisConnectionFactory; + @MockBean StringRedisTemplate stringRedisTemplate; + @MockBean JavaMailSender javaMailSender; + + @Autowired MockMvc mockMvc; + @Autowired UserRepository userRepository; + + @BeforeEach + void clean() { + userRepository.findByEmail(EMAIL).ifPresent(userRepository::delete); + } + + private int register(String json) throws Exception { + return mockMvc.perform(post("/auth/register").contentType(MediaType.APPLICATION_JSON).content(json)) + .andReturn().getResponse().getStatus(); + } + + @Test + @DisplayName("요청에 role·provider·emailVerified 를 넣어도 서버가 정한 값으로 저장된다") + void serverControlledFieldsCannotBeSetByClient() throws Exception { + int status = register(""" + {"email":"%s","password":"secret123","name":"공격자", + "role":"ADMIN","provider":"kakao","providerId":"1","emailVerified":true,"isActive":true} + """.formatted(EMAIL)); + + assertThat(status).isEqualTo(200); + User saved = userRepository.findByEmail(EMAIL).orElseThrow(); + assertThat(saved.getRole()).isEqualTo(UserRole.PARENT); + assertThat(saved.getProvider()).isNull(); + assertThat(saved.getProviderId()).isNull(); + assertThat(saved.getEmailVerified()).isFalse(); + } + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = { + // 빈 이메일 + "{\"email\":\"\",\"password\":\"secret123\",\"name\":\"이름\"}", + // 형식이 틀린 이메일 + "{\"email\":\"not-an-email\",\"password\":\"secret123\",\"name\":\"이름\"}", + // 짧은 비밀번호 + "{\"email\":\"signup-contract@example.com\",\"password\":\"1\",\"name\":\"이름\"}", + // 이름 없음 — 예전에는 DB NOT NULL 제약에서 500 이었다 + "{\"email\":\"signup-contract@example.com\",\"password\":\"secret123\"}", + // 성별 값 오류 — 예전에는 Gender.valueOf 에서 500 이었다 + "{\"email\":\"signup-contract@example.com\",\"password\":\"secret123\",\"name\":\"이름\",\"gender\":\"X\"}", + // 날짜 형식 오류 — 예전에는 역직렬화 실패가 500 이었다 + "{\"email\":\"signup-contract@example.com\",\"password\":\"secret123\",\"name\":\"이름\",\"birthDate\":\"어제\"}" + }) + @DisplayName("잘못된 가입 요청은 400 이고 계정이 생기지 않는다") + void invalidRequestsAreRejected(String json) throws Exception { + assertThat(register(json)).isEqualTo(400); + assertThat(userRepository.findByEmail(EMAIL)).isEmpty(); + } +}