diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java b/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java index 1c375695..9a9878e7 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java @@ -20,13 +20,13 @@ @RequestMapping("/api/auth") @RequiredArgsConstructor @Slf4j -@Tag(name = "Member", description = "회원 관리 API") +@Tag(name = "회원 관리", description = "회원 관리 API") public class MemberController { private final MemberService memberService; @PostMapping("/signup") - @Operation(summary = "회원가입", description = "새로운 회원을 등록합니다.") + @Operation(summary = "01. 회원가입", description = "새로운 회원을 등록합니다.") @ApiResponses({ @ApiResponse(responseCode = "201", description = "회원가입 성공"), @ApiResponse(responseCode = "400", description = "잘못된 요청 (중복 이메일/닉네임, 유효성 검증 실패)") @@ -40,7 +40,7 @@ public ResponseEntity signup(@Valid @RequestBody MemberSignupReq } @PostMapping("/login") - @Operation(summary = "로그인", description = "이메일과 비밀번호로 로그인합니다.") + @Operation(summary = "02. 로그인", description = "이메일과 비밀번호로 로그인합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "로그인 성공"), @ApiResponse(responseCode = "401", description = "인증 실패 (존재하지 않는 회원, 비밀번호 불일치)") @@ -55,7 +55,7 @@ public ResponseEntity login(@Valid @RequestBody MemberLoginReque } @PostMapping("/logout") - @Operation(summary = "로그아웃", description = "현재 로그인된 사용자를 로그아웃합니다.") + @Operation(summary = "08. 로그아웃", description = "현재 로그인된 사용자를 로그아웃합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "로그아웃 성공") }) @@ -76,7 +76,7 @@ public ResponseEntity logout(Authentication authentication, HttpServletRes } @PostMapping("/refresh") - @Operation(summary = "토큰 재발급", description = "리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다.") + @Operation(summary = "04. 토큰 재발급", description = "리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "토큰 재발급 성공"), @ApiResponse(responseCode = "401", description = "유효하지 않은 리프레시 토큰") @@ -98,7 +98,7 @@ public ResponseEntity refreshToken(HttpServletRequest request, } @DeleteMapping("/withdraw") - @Operation(summary = "회원탈퇴", description = "현재 로그인된 사용자의 계정을 삭제합니다.") + @Operation(summary = "09. 회원탈퇴", description = "현재 로그인된 사용자의 계정을 삭제합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "회원탈퇴 성공"), @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자"), @@ -120,7 +120,7 @@ public ResponseEntity withdraw(Authentication authentication, HttpServletR } @GetMapping("/me") - @Operation(summary = "내 정보 조회", description = "현재 로그인된 사용자의 정보를 조회합니다.") + @Operation(summary = "03. 내 정보 조회", description = "현재 로그인된 사용자의 정보를 조회합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "조회 성공"), @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자") @@ -139,7 +139,7 @@ public ResponseEntity getMyInfo(Authentication authentication) { } @PostMapping("/sendEmail") - @Operation(summary = "이메일 인증번호 전송", description = "로그인된 사용자는 자동으로 인증번호를 받고, 비로그인 사용자는 요청 바디의 loginId(이메일)로 인증번호를 받습니다.") + @Operation(summary = "05. 인증번호 전송", description = "로그인된 사용자는 자동으로 인증번호를 받고, 비로그인 사용자는 요청 바디의 loginId(이메일)로 인증번호를 받습니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "이메일 전송 성공"), @ApiResponse(responseCode = "400", description = "잘못된 요청 (loginId 없음)") @@ -198,7 +198,7 @@ public ResponseEntity sendEmail( } @PostMapping("/verifyEmail") - @Operation(summary = "이메일 인증번호 검증", description = "로그인된 사용자는 자동으로 인증번호를 검증하고, 비로그인 사용자는 요청 바디의 loginId(이메일)와 함께 인증번호를 검증합니다.") + @Operation(summary = "06. 인증번호 검증", description = "로그인된 사용자는 자동으로 인증번호를 검증하고, 비로그인 사용자는 요청 바디의 loginId(이메일)와 함께 인증번호를 검증합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "인증번호 검증 성공"), @ApiResponse(responseCode = "400", description = "잘못된 요청 (인증번호 불일치, loginId 없음)") @@ -265,18 +265,63 @@ public ResponseEntity verifyEmail( // ===== 비밀번호 재설정 엔드포인트 ===== @PostMapping("/password-reset/reset") - @Operation(summary = "비밀번호 재설정", description = "인증 토큰과 함께 새 비밀번호로 재설정합니다.") + @Operation(summary = "07. 비밀번호 재설정", description = "인증 토큰과 함께 새 비밀번호로 재설정합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "비밀번호 재설정 성공"), @ApiResponse(responseCode = "400", description = "인증되지 않았거나 잘못된 요청") }) - public ResponseEntity resetPassword(@Valid @RequestBody ResetPasswordRequestDto request) { - log.info("비밀번호 재설정 요청: email={}", request.getLoginId()); + public ResponseEntity resetPassword( + @RequestBody ResetPasswordRequestDto request, + Authentication authentication, + HttpServletRequest httpRequest) { + + // 입력값 검증 + if (request.getNewPassword() == null || request.getNewPassword().isBlank()) { + throw new IllegalArgumentException("새 비밀번호를 입력해주세요."); + } + if (request.getSuccess() == null) { + throw new IllegalArgumentException("인증 성공 여부가 필요합니다."); + } + + String loginId = null; + + // 1. 로그인된 사용자인 경우 JWT 토큰에서 loginId 추출 (우선순위 1) + if (authentication != null && authentication.isAuthenticated() && + !"anonymousUser".equals(authentication.getPrincipal())) { + + // JWT 토큰에서 직접 loginid claim 추출 + try { + String token = extractAccessTokenFromRequest(httpRequest); + if (token != null) { + loginId = memberService.extractLoginIdFromToken(token); + if (loginId != null) { + log.info("JWT 토큰에서 loginId 추출 성공: {}", loginId); + } else { + log.warn("JWT 토큰에서 loginId 추출 실패"); + } + } + } catch (Exception e) { + log.warn("JWT 토큰에서 loginId 추출 중 오류: {}", e.getMessage()); + } + } + + // 2. 비로그인 사용자인 경우 요청 바디에서 loginId 추출 (우선순위 2) + if (loginId == null) { + if (request.getLoginId() != null && !request.getLoginId().isBlank()) { + loginId = request.getLoginId(); + log.info("요청 바디에서 loginId 추출 성공: {}", loginId); + } else { + log.error("로그인하지 않은 상태에서 요청 바디에 loginId가 없음"); + throw new IllegalArgumentException("비밀번호를 재설정할 이메일 주소가 필요합니다. 로그인하거나 요청 바디에 loginId를 포함해주세요."); + } + } + + log.info("비밀번호 재설정 요청: email={}", loginId); - memberService.resetPassword(request.getLoginId(), request.getNewPassword(), request.getSuccess()); + memberService.resetPassword(loginId, request.getNewPassword(), request.getSuccess()); - log.info("비밀번호 재설정 성공: email={}", request.getLoginId()); - return ResponseEntity.ok(PasswordResetResponse.success("비밀번호가 성공적으로 재설정되었습니다.", request.getLoginId())); + log.info("비밀번호 재설정 성공: email={}", loginId); + return ResponseEntity.ok(PasswordResetResponse.success("비밀번호가 성공적으로 재설정되었습니다.", loginId)); } /** diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/dto/ResetPasswordRequestDto.java b/backend/src/main/java/com/ai/lawyer/domain/member/dto/ResetPasswordRequestDto.java index 6f25345e..fdd9b60c 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/dto/ResetPasswordRequestDto.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/dto/ResetPasswordRequestDto.java @@ -8,7 +8,6 @@ @Getter @Setter public class ResetPasswordRequestDto { - @NotBlank(message = "이메일을 입력해주세요.") private String loginId; @NotBlank(message = "새 비밀번호를 입력해주세요.") diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java b/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java index fb60bdaf..3a80590c 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java @@ -141,8 +141,17 @@ public void resetPassword(String loginId, String newPassword, Boolean success) { Member member = memberRepository.findByLoginId(loginId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); - // 인증 성공 여부 확인 - if (!Boolean.TRUE.equals(success)) { + // 클라이언트에서 전달한 success 값과 Redis의 인증 성공 여부를 모두 확인 + boolean clientSuccess = Boolean.TRUE.equals(success); + + // 클라이언트 success가 false면 바로 실패 + if (!clientSuccess) { + throw new IllegalArgumentException("이메일 인증을 완료해야 비밀번호를 재설정할 수 있습니다."); + } + + // 클라이언트 success가 true면 Redis 인증 상태도 확인 + boolean redisVerified = emailAuthService.isEmailVerified(loginId); + if (!redisVerified) { throw new IllegalArgumentException("이메일 인증을 완료해야 비밀번호를 재설정할 수 있습니다."); } @@ -151,6 +160,9 @@ public void resetPassword(String loginId, String newPassword, Boolean success) { member.updatePassword(encodedPassword); memberRepository.save(member); + // 인증 데이터 삭제 (비밀번호 재설정 완료 후) + emailAuthService.clearAuthData(loginId); + // 기존 리프레시 토큰 삭제 (보안상 로그아웃 처리) tokenProvider.deleteRefreshToken(loginId); } diff --git a/backend/src/main/java/com/ai/lawyer/global/config/RedisConfig.java b/backend/src/main/java/com/ai/lawyer/global/config/RedisConfig.java index 3444e55e..99717ebc 100644 --- a/backend/src/main/java/com/ai/lawyer/global/config/RedisConfig.java +++ b/backend/src/main/java/com/ai/lawyer/global/config/RedisConfig.java @@ -1,14 +1,21 @@ package com.ai.lawyer.global.config; +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.ContextClosedEvent; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.EventListener; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.StringRedisSerializer; +import redis.embedded.RedisServer; +@Slf4j @Configuration @ConditionalOnProperty(name = "spring.data.redis.embedded", havingValue = "false") public class RedisConfig { @@ -19,8 +26,11 @@ public class RedisConfig { @Value("${spring.data.redis.port:6379}") private int redisPort; + private RedisServer redisServer; + @Bean public RedisConnectionFactory redisConnectionFactory() { + log.info("=== RedisConnectionFactory 생성: host={}, port={} ===", redisHost, redisPort); return new LettuceConnectionFactory(redisHost, redisPort); } @@ -34,6 +44,33 @@ public RedisTemplate redisTemplate() { redisTemplate.setHashKeySerializer(new StringRedisSerializer()); redisTemplate.setHashValueSerializer(new StringRedisSerializer()); + log.info("=== RedisTemplate 설정 완료 (host={}, port={}) ===", redisHost, redisPort); return redisTemplate; } + + @EventListener(ContextRefreshedEvent.class) + public void startRedis() { + try { + redisServer = RedisServer.builder() + .port(redisPort) + .setting("maxmemory 128M") + .build(); + + if (!redisServer.isActive()) { + redisServer.start(); + log.info("=== Redis 서버가 포트 {}에서 시작되었습니다 ===", redisPort); + } + } catch (Exception e) { + log.error("=== Redis 서버 시작 실패: {} ===", e.getMessage(), e); + } + } + + @PreDestroy + @EventListener(ContextClosedEvent.class) + public void stopRedis() { + if (redisServer != null && redisServer.isActive()) { + redisServer.stop(); + log.info("=== Redis 서버가 중지되었습니다 ==="); + } + } } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/global/email/service/EmailAuthService.java b/backend/src/main/java/com/ai/lawyer/global/email/service/EmailAuthService.java index ff1f8b7e..91b039d2 100644 --- a/backend/src/main/java/com/ai/lawyer/global/email/service/EmailAuthService.java +++ b/backend/src/main/java/com/ai/lawyer/global/email/service/EmailAuthService.java @@ -30,14 +30,39 @@ public boolean verifyAuthCode(String loginId, String inputCode) { String key = buildKey(loginId); String savedCode = (String) redisTemplate.opsForValue().get(key); if (savedCode != null && savedCode.equals(inputCode)) { - redisTemplate.delete(key); // 성공 시 삭제 + // 성공 시 삭제하지 않고 인증 성공 표시로 업데이트 + String successKey = buildSuccessKey(loginId); + redisTemplate.opsForValue().set(successKey, "true", EXPIRATION_MINUTES, TimeUnit.MINUTES); return true; } return false; } + /** + * 이메일 인증 성공 여부 확인 + */ + public boolean isEmailVerified(String loginId) { + String successKey = buildSuccessKey(loginId); + String isVerified = (String) redisTemplate.opsForValue().get(successKey); + return "true".equals(isVerified); + } + + /** + * 비밀번호 재설정 완료 후 인증 데이터 삭제 + */ + public void clearAuthData(String loginId) { + String key = buildKey(loginId); + String successKey = buildSuccessKey(loginId); + redisTemplate.delete(key); + redisTemplate.delete(successKey); + } + private String buildKey(String loginId) { return "email:auth:" + loginId; } + private String buildSuccessKey(String loginId) { + return "email:auth:success:" + loginId; + } + } diff --git a/backend/src/main/java/com/ai/lawyer/global/springDoc/SpringDocConfig.java b/backend/src/main/java/com/ai/lawyer/global/springDoc/SpringDocConfig.java index d7fc8ca9..40caed46 100644 --- a/backend/src/main/java/com/ai/lawyer/global/springDoc/SpringDocConfig.java +++ b/backend/src/main/java/com/ai/lawyer/global/springDoc/SpringDocConfig.java @@ -4,10 +4,19 @@ import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; import io.swagger.v3.oas.annotations.info.Info; import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import org.springdoc.core.customizers.OpenApiCustomizer; import org.springdoc.core.models.GroupedOpenApi; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + @Configuration @OpenAPIDefinition(info = @Info(title = "AI Lawyer API", version = "beta", description = "AI 변호사 서비스 API 문서입니다.")) @SecurityScheme( @@ -24,6 +33,7 @@ public GroupedOpenApi memberApi() { .group("Member API") .pathsToMatch("/api/auth/**") .packagesToScan("com.ai.lawyer.domain.member.controller") + .addOpenApiCustomizer(orderBySummaryNumber()) .build(); } @@ -53,4 +63,33 @@ public GroupedOpenApi allApi() { .packagesToScan("com.ai.lawyer.domain") .build(); } + + private OpenApiCustomizer orderBySummaryNumber() { + return openApi -> { + if (openApi.getPaths() == null) return; + + Map sortedPaths = new LinkedHashMap<>(); + + // 정렬을 위해 summary 안에 있는 번호 추출 + Pattern pattern = Pattern.compile("^(\\d+)\\..*"); + + openApi.getPaths().entrySet().stream() + .sorted(Comparator.comparingInt(e -> { + PathItem pathItem = e.getValue(); + // POST, GET, 등등 중 첫 번째 Operation의 summary 사용 + Operation op = pathItem.readOperations().stream().findFirst().orElse(null); + if (op == null || op.getSummary() == null) return Integer.MAX_VALUE; + + Matcher matcher = pattern.matcher(op.getSummary()); + if (matcher.find()) { + return Integer.parseInt(matcher.group(1)); + } + return Integer.MAX_VALUE; + })) + .forEachOrdered(entry -> sortedPaths.put(entry.getKey(), entry.getValue())); + + openApi.setPaths(new io.swagger.v3.oas.models.Paths()); + sortedPaths.forEach(openApi.getPaths()::addPathItem); + }; + } } \ No newline at end of file diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml index a65ccf8b..2bf06544 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev.yml @@ -15,4 +15,4 @@ spring: host: ${DEV_REDIS_HOST} port: ${DEV_REDIS_PORT} password: ${DEV_REDIS_PASSWORD} - embedded: true + embedded: false diff --git a/backend/src/test/java/com/ai/lawyer/domain/member/controller/MemberControllerTest.java b/backend/src/test/java/com/ai/lawyer/domain/member/controller/MemberControllerTest.java index 73684276..32a9e967 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/member/controller/MemberControllerTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/member/controller/MemberControllerTest.java @@ -495,8 +495,8 @@ void verifyEmail_Fail_ValidationError() throws Exception { // ===== 비밀번호 재설정 관련 테스트 ===== @Test - @DisplayName("비밀번호 재설정 성공") - void resetPassword_Success() throws Exception { + @DisplayName("비밀번호 재설정 성공 - 요청 바디에서 loginId 추출") + void resetPassword_Success_WithLoginIdInBody() throws Exception { // given ResetPasswordRequestDto requestDto = new ResetPasswordRequestDto(); requestDto.setLoginId("test@example.com"); @@ -565,12 +565,32 @@ void resetPassword_Fail_MemberNotFound() throws Exception { verify(memberService).resetPassword("nonexistent@example.com", "newPassword123", true); } + @Test + @DisplayName("비밀번호 재설정 성공 - 토큰 없이 loginId만으로") + void resetPassword_Success_WithoutToken() throws Exception { + // given - loginId 없이 요청 + ResetPasswordRequestDto requestDto = new ResetPasswordRequestDto(); + requestDto.setNewPassword("newPassword123"); + requestDto.setSuccess(true); + // loginId는 설정하지 않음 + + // when and then - loginId가 없으면 예외가 발생해야 함 + mockMvc.perform(post("/api/auth/password-reset/reset") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andDo(print()) + .andExpect(status().isBadRequest()); + + verify(memberService, never()).resetPassword(anyString(), anyString(), any()); + } + @Test @DisplayName("비밀번호 재설정 실패 - 유효성 검증 실패") void resetPassword_Fail_ValidationError() throws Exception { // given ResetPasswordRequestDto invalidRequest = new ResetPasswordRequestDto(); - invalidRequest.setLoginId(""); // 빈 이메일 + // loginId는 이제 optional이므로 빈 문자열 사용 (null은 허용) invalidRequest.setNewPassword(""); // 빈 비밀번호 invalidRequest.setSuccess(null); // null success diff --git a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java index 995eec95..f1d258be 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java @@ -487,7 +487,7 @@ void verifyAuthCode_Fail_MemberNotFound() { // ===== 비밀번호 재설정 관련 테스트 ===== @Test - @DisplayName("비밀번호 재설정 성공") + @DisplayName("비밀번호 재설정 성공 - 클라이언트 success와 Redis 인증 모두 확인") void resetPassword_Success() { // given log.info("=== 비밀번호 재설정 성공 테스트 시작 ==="); @@ -497,9 +497,11 @@ void resetPassword_Success() { log.info("비밀번호 재설정: 이메일={}, 인증성공={}", loginId, success); given(memberRepository.findByLoginId(loginId)).willReturn(Optional.of(member)); + given(emailAuthService.isEmailVerified(loginId)).willReturn(true); // Redis 인증 성공 given(passwordEncoder.encode(newPassword)).willReturn("encodedNewPassword"); doNothing().when(tokenProvider).deleteRefreshToken(loginId); - log.info("Mock 설정 완료: 회원 존재, 인증 성공, 비밀번호 인코딩 준비"); + doNothing().when(emailAuthService).clearAuthData(loginId); + log.info("Mock 설정 완료: 회원 존재, 클라이언트 인증 성공, Redis 인증 성공, 비밀번호 인코딩 준비"); // when log.info("비밀번호 재설정 서비스 호출 중..."); @@ -510,23 +512,28 @@ void resetPassword_Success() { log.info("검증 시작: 비밀번호 재설정 프로세스 확인"); verify(memberRepository).findByLoginId(loginId); log.info("회원 존재 여부 조회 호출 확인"); + verify(emailAuthService).isEmailVerified(loginId); + log.info("Redis 이메일 인증 성공 여부 확인 호출 확인"); verify(passwordEncoder).encode(newPassword); log.info("새 비밀번호 인코딩 호출 확인"); verify(memberRepository).save(member); log.info("회원 정보 저장 호출 확인"); + verify(emailAuthService).clearAuthData(loginId); + log.info("인증 데이터 삭제 호출 확인"); verify(tokenProvider).deleteRefreshToken(loginId); log.info("기존 리프레시 토큰 삭제 호출 확인 (보안상 로그아웃 처리)"); log.info("=== 비밀번호 재설정 성공 테스트 완료 ==="); } @Test - @DisplayName("비밀번호 재설정 실패 - 인증되지 않음 (success = false)") - void resetPassword_Fail_NotAuthenticated() { + @DisplayName("비밀번호 재설정 실패 - 클라이언트 success = false") + void resetPassword_Fail_ClientSuccessFalse() { // given String loginId = "test@example.com"; String newPassword = "newPassword123"; Boolean success = false; given(memberRepository.findByLoginId(loginId)).willReturn(Optional.of(member)); + // Redis Mock 설정 제거 - 클라이언트 success가 false면 Redis 확인 안함 // when and then assertThatThrownBy(() -> memberService.resetPassword(loginId, newPassword, success)) @@ -534,6 +541,30 @@ void resetPassword_Fail_NotAuthenticated() { .hasMessage("이메일 인증을 완료해야 비밀번호를 재설정할 수 있습니다."); verify(memberRepository).findByLoginId(loginId); + // 클라이언트 success가 false이면 Redis 확인 전에 실패 + verify(emailAuthService, never()).isEmailVerified(anyString()); + verify(passwordEncoder, never()).encode(anyString()); + verify(memberRepository, never()).save(any(Member.class)); + verify(tokenProvider, never()).deleteRefreshToken(anyString()); + } + + @Test + @DisplayName("비밀번호 재설정 실패 - Redis 인증되지 않음") + void resetPassword_Fail_RedisNotVerified() { + // given + String loginId = "test@example.com"; + String newPassword = "newPassword123"; + Boolean success = true; + given(memberRepository.findByLoginId(loginId)).willReturn(Optional.of(member)); + given(emailAuthService.isEmailVerified(loginId)).willReturn(false); // Redis 인증 실패 + + // when and then + assertThatThrownBy(() -> memberService.resetPassword(loginId, newPassword, success)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("이메일 인증을 완료해야 비밀번호를 재설정할 수 있습니다."); + + verify(memberRepository).findByLoginId(loginId); + verify(emailAuthService).isEmailVerified(loginId); verify(passwordEncoder, never()).encode(anyString()); verify(memberRepository, never()).save(any(Member.class)); verify(tokenProvider, never()).deleteRefreshToken(anyString()); diff --git a/backend/src/test/java/com/ai/lawyer/global/email/service/EmailAuthServiceTest.java b/backend/src/test/java/com/ai/lawyer/global/email/service/EmailAuthServiceTest.java new file mode 100644 index 00000000..5f1bf445 --- /dev/null +++ b/backend/src/test/java/com/ai/lawyer/global/email/service/EmailAuthServiceTest.java @@ -0,0 +1,239 @@ +package com.ai.lawyer.global.email.service; + +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("EmailAuthService 테스트") +class EmailAuthServiceTest { + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + @InjectMocks + private EmailAuthService emailAuthService; + + private String loginId; + private String authKey; + private String successKey; + + @BeforeEach + void setUp() { + loginId = "test@example.com"; + authKey = "email:auth:" + loginId; + successKey = "email:auth:success:" + loginId; + } + + @Test + @DisplayName("인증번호 생성 및 저장 성공") + void generateAndSaveAuthCode_Success() { + // given + given(redisTemplate.opsForValue()).willReturn(valueOperations); + doNothing().when(valueOperations).set(eq(authKey), anyString(), eq(5L), eq(TimeUnit.MINUTES)); + + // when + String authCode = emailAuthService.generateAndSaveAuthCode(loginId); + + // then + assertThat(authCode).isNotNull(); + assertThat(authCode).hasSize(6); + assertThat(authCode).matches("\\d{6}"); // 6자리 숫자 패턴 확인 + + verify(redisTemplate).opsForValue(); + verify(valueOperations).set(eq(authKey), eq(authCode), eq(5L), eq(TimeUnit.MINUTES)); + } + + @Test + @DisplayName("인증번호 검증 성공 - 올바른 인증번호") + void verifyAuthCode_Success() { + // given + given(redisTemplate.opsForValue()).willReturn(valueOperations); + String inputCode = "123456"; + given(valueOperations.get(authKey)).willReturn(inputCode); + doNothing().when(valueOperations).set(eq(successKey), eq("true"), eq(5L), eq(TimeUnit.MINUTES)); + + // when + boolean result = emailAuthService.verifyAuthCode(loginId, inputCode); + + // then + assertThat(result).isTrue(); + + verify(redisTemplate, times(2)).opsForValue(); // get과 set 호출 + verify(valueOperations).get(authKey); + verify(valueOperations).set(successKey, "true", 5L, TimeUnit.MINUTES); + } + + @Test + @DisplayName("인증번호 검증 실패 - 잘못된 인증번호") + void verifyAuthCode_Fail_InvalidCode() { + // given + given(redisTemplate.opsForValue()).willReturn(valueOperations); + String savedCode = "123456"; + String inputCode = "999999"; + given(valueOperations.get(authKey)).willReturn(savedCode); + + // when + boolean result = emailAuthService.verifyAuthCode(loginId, inputCode); + + // then + assertThat(result).isFalse(); + + verify(redisTemplate).opsForValue(); + verify(valueOperations).get(authKey); + verify(valueOperations, never()).set(anyString(), anyString(), anyLong(), any(TimeUnit.class)); + } + + @Test + @DisplayName("인증번호 검증 실패 - 저장된 인증번호 없음") + void verifyAuthCode_Fail_NoSavedCode() { + // given + given(redisTemplate.opsForValue()).willReturn(valueOperations); + String inputCode = "123456"; + given(valueOperations.get(authKey)).willReturn(null); + + // when + boolean result = emailAuthService.verifyAuthCode(loginId, inputCode); + + // then + assertThat(result).isFalse(); + + verify(redisTemplate).opsForValue(); + verify(valueOperations).get(authKey); + verify(valueOperations, never()).set(anyString(), anyString(), anyLong(), any(TimeUnit.class)); + } + + @Test + @DisplayName("이메일 인증 성공 여부 확인 - 인증됨") + void isEmailVerified_True() { + // given + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.get(successKey)).willReturn("true"); + + // when + boolean result = emailAuthService.isEmailVerified(loginId); + + // then + assertThat(result).isTrue(); + + verify(redisTemplate).opsForValue(); + verify(valueOperations).get(successKey); + } + + @Test + @DisplayName("이메일 인증 성공 여부 확인 - 인증되지 않음") + void isEmailVerified_False() { + // given + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.get(successKey)).willReturn(null); + + // when + boolean result = emailAuthService.isEmailVerified(loginId); + + // then + assertThat(result).isFalse(); + + verify(redisTemplate).opsForValue(); + verify(valueOperations).get(successKey); + } + + @Test + @DisplayName("이메일 인증 성공 여부 확인 - false 값") + void isEmailVerified_FalseValue() { + // given + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.get(successKey)).willReturn("false"); + + // when + boolean result = emailAuthService.isEmailVerified(loginId); + + // then + assertThat(result).isFalse(); + + verify(redisTemplate).opsForValue(); + verify(valueOperations).get(successKey); + } + + @Test + @DisplayName("인증 데이터 삭제 성공") + void clearAuthData_Success() { + // given + given(redisTemplate.delete(authKey)).willReturn(true); + given(redisTemplate.delete(successKey)).willReturn(true); + + // when + emailAuthService.clearAuthData(loginId); + + // then + verify(redisTemplate).delete(authKey); + verify(redisTemplate).delete(successKey); + } + + @Test + @DisplayName("여러 사용자 인증번호 생성 - 각각 고유한 키 사용") + void generateAuthCode_MultipleUsers() { + // given + given(redisTemplate.opsForValue()).willReturn(valueOperations); + String loginId1 = "user1@example.com"; + String loginId2 = "user2@example.com"; + String authKey1 = "email:auth:" + loginId1; + String authKey2 = "email:auth:" + loginId2; + + doNothing().when(valueOperations).set(eq(authKey1), anyString(), eq(5L), eq(TimeUnit.MINUTES)); + doNothing().when(valueOperations).set(eq(authKey2), anyString(), eq(5L), eq(TimeUnit.MINUTES)); + + // when + String code1 = emailAuthService.generateAndSaveAuthCode(loginId1); + String code2 = emailAuthService.generateAndSaveAuthCode(loginId2); + + // then + assertThat(code1).isNotNull().hasSize(6); + assertThat(code2).isNotNull().hasSize(6); + assertThat(code1).isNotEqualTo(code2); // 대부분의 경우 다른 코드가 생성됨 + + verify(valueOperations).set(eq(authKey1), eq(code1), eq(5L), eq(TimeUnit.MINUTES)); + verify(valueOperations).set(eq(authKey2), eq(code2), eq(5L), eq(TimeUnit.MINUTES)); + } + + @Test + @DisplayName("인증 성공 후 성공 상태 확인 플로우") + void authenticationFlow_Success() { + // given + given(redisTemplate.opsForValue()).willReturn(valueOperations); + String inputCode = "123456"; + + // 1. 인증번호 검증 + given(valueOperations.get(authKey)).willReturn(inputCode); + doNothing().when(valueOperations).set(eq(successKey), eq("true"), eq(5L), eq(TimeUnit.MINUTES)); + + // 2. 성공 상태 확인 + given(valueOperations.get(successKey)).willReturn("true"); + + // when + boolean verifyResult = emailAuthService.verifyAuthCode(loginId, inputCode); + boolean isVerified = emailAuthService.isEmailVerified(loginId); + + // then + assertThat(verifyResult).isTrue(); + assertThat(isVerified).isTrue(); + + verify(valueOperations).get(authKey); + verify(valueOperations).set(successKey, "true", 5L, TimeUnit.MINUTES); + verify(valueOperations).get(successKey); + } +} \ No newline at end of file