diff --git a/.github/workflows/cd-deploy.yml b/.github/workflows/cd-deploy.yml index 60a9f59d..d0cf055f 100644 --- a/.github/workflows/cd-deploy.yml +++ b/.github/workflows/cd-deploy.yml @@ -47,18 +47,18 @@ jobs: - name: Configure Docker for GCR run: gcloud auth configure-docker - + - name: Build Docker Image run: | docker build -t $IMAGE_NAME:$GITHUB_SHA \ -t $IMAGE_NAME:latest \ . - + - name: Push Docker Image to GCR run: | docker push $IMAGE_NAME:$GITHUB_SHA docker push $IMAGE_NAME:latest - + - name: Deploy to Cloud Run run: | gcloud run deploy $SERVICE_NAME \ @@ -73,12 +73,12 @@ jobs: --max-instances 10 \ --min-instances 0 \ --timeout=900 \ - --startup-cpu-boost \ + --cpu-boost \ --execution-environment gen2 \ --port=8080 \ --set-env-vars="SPRING_PROFILES_ACTIVE=prod,DB_NAME=${{ secrets.DB_NAME }},DB_USERNAME=cmall,SPRING_APPLICATION_NAME=feedshop,PORT=8080,SERVER_SSL_ENABLED=false,SERVER_ADDRESS=0.0.0.0" \ --update-secrets="DB_PASSWORD=shopchat-db-password:latest,MAILGUN_API_KEY=mailgun_api_key:latest,MAILGUN_DOMAIN=mailgun_domain:latest,MAILGUN_EMAIL=mailgun_email:latest,GCS_ID=gcs_id:latest,GCS_BUCKET=gcs_prod_bucket:latest,JWT_SECRET=feedshop-jwt-secret-key:latest,RECAPTCHA_SECRET_KEY=recaptcha_secret_key:latest,GOOGLE_CLIENT_ID=google_client_id:latest,GOOGLE_CLIENT_SECRET=google_client_secret:latest,KAKAO_CLIENT_ID=kakao_client_id:latest,KAKAO_CLIENT_SECRET=kakao_client_secret:latest,OPENAI_API_KEY=openAI_api_key:latest" - + - name: Wait for deployment run: | echo "Waiting for deployment to complete..." @@ -86,7 +86,7 @@ jobs: # 서비스 상태 확인 gcloud run services describe $SERVICE_NAME --region=$REGION --format="value(status.conditions[0].status,status.conditions[0].message)" - + - name: Check deployment status and logs if: failure() run: | @@ -100,13 +100,13 @@ jobs: echo "=== Service Status ===" gcloud run services describe $SERVICE_NAME --region=$REGION --format="yaml(status)" - + - name: Get Service URL run: | SERVICE_URL=$(gcloud run services describe $SERVICE_NAME --region=$REGION --format="value(status.url)") echo "Service deployed at: $SERVICE_URL" echo "SERVICE_URL=$SERVICE_URL" >> $GITHUB_ENV - + - name: Health Check run: | echo "Waiting for service to be ready..." @@ -147,4 +147,4 @@ jobs: else echo "❌ Deployment failed!" exit 1 - fi + fi \ No newline at end of file diff --git a/README.md b/README.md index 1c81cad1..8ff93654 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ - [📖 API 문서](#-api-문서) - [🧪 테스트](#-테스트) - [🔧 개발 환경](#-개발-환경) -- [📈 CI/CD](#cicd) +- [📈 CI/CD](#-cicd) - [🤝 기여 방법](#-기여-방법) - [📝 라이선스](#-라이선스) @@ -35,7 +35,6 @@ - **상품 관리**: 상품 등록, 수정, 삭제, 옵션 관리, 이미지 업로드 - **장바구니**: 상품 추가/삭제, 수량 변경, 선택 상품 관리 - **주문 시스템**: 주문 생성, 주문 내역 조회, 재고 관리, 포인트 사용 -- **결제 연동**: 다양한 결제 수단 지원 (구현 예정) ### 👤 사용자 관리 @@ -67,97 +66,16 @@ ### 🤖 AI 기능 - **상품 추천**: OpenAI 기반 개인화 상품 추천 -- **AI 챗봇**: 상품 문의 및 고객 지원 (구현 예정) -- **스마트 검색**: AI 기반 상품 검색 및 필터링 (구현 예정) +- **스마트 검색**: AI 기반 상품 검색 및 필터링 --- + ## 🏗️ 아키텍처 ### 전체 시스템 아키텍처 +image -```mermaid -graph TB - %% Frontend Layer - subgraph "Frontend (Vercel)" - FE["React Frontend
🌐 www.feedshop.store"] - end - - %% CDN & Storage - subgraph "Static Assets" - CDN["CDN
📁 cdn-feedshop.store
(Google Cloud Storage)"] - end - - %% Backend Services - subgraph "GCP Backend Services" - subgraph "Development Environment" - DEV_APP["Development API
🔧 Spring Boot
(Local/Dev Server)"] - DEV_DB[(Development DB
🗄️ MySQL
Compute Engine + Docker)] - end - - subgraph "Production Environment" - PROD_APP["Production API
🚀 Spring Boot
Cloud Run
feedshop-springboot-561086069695.asia-northeast3.run.app"] - PROD_DB[(Production DB
☁️ Cloud SQL MySQL
feedshop-db)] - end - end - - %% External Services - subgraph "External Services" - MAILGUN["Mailgun
📧 Email Service"] - RECAPTCHA["Google reCAPTCHA
🛡️ Bot Protection"] - SONAR["SonarCloud
📊 Code Quality"] - OPENAI["OpenAI
🤖 AI Services"] - OAUTH["OAuth2 Providers
🔐 Google, Kakao"] - end - - %% CI/CD Pipeline - subgraph "CI/CD Pipeline" - GITHUB["GitHub Repository
📚 Source Code"] - GH_ACTIONS["GitHub Actions
⚙️ CI/CD Pipeline"] - end - - %% User Interactions - USER["👤 Users"] - DEV["👨‍💻 Developers"] - - %% Frontend Connections - USER --> FE - FE --> PROD_APP - FE --> CDN - - %% Development Flow - DEV --> GITHUB - DEV_APP --> DEV_DB - - %% Production Flow - PROD_APP --> PROD_DB - PROD_APP --> CDN - PROD_APP --> MAILGUN - PROD_APP --> RECAPTCHA - PROD_APP --> OPENAI - PROD_APP --> OAUTH - - %% CI/CD Flow - GITHUB --> GH_ACTIONS - GH_ACTIONS --> SONAR - GH_ACTIONS -->|Deploy to Main| PROD_APP - GH_ACTIONS -->|Build & Test| DEV_APP - - %% Styling - classDef frontend fill:#e1f5fe,stroke:#01579b,stroke-width:2px - classDef backend fill:#f3e5f5,stroke:#4a148c,stroke-width:2px - classDef database fill:#fff3e0,stroke:#e65100,stroke-width:2px - classDef external fill:#e8f5e8,stroke:#1b5e20,stroke-width:2px - classDef cicd fill:#fff8e1,stroke:#ff6f00,stroke-width:2px - classDef user fill:#fce4ec,stroke:#880e4f,stroke-width:2px - - class FE frontend - class DEV_APP,PROD_APP backend - class DEV_DB,PROD_DB database - class MAILGUN,RECAPTCHA,SONAR,OPENAI,OAUTH external - class GITHUB,GH_ACTIONS cicd - class USER,DEV user -``` ### 인프라 구성 요소 @@ -217,7 +135,7 @@ src/main/java/com/cMall/feedShop/ | **Feed** | ✅ 완료 | 피드 작성, 조회, 좋아요, 댓글 | 높음 | | **Event** | ✅ 완료 | 이벤트 관리, 검색, 필터링 | 높음 | | **Store** | ✅ 완료 | 스토어 정보 관리 | 높음 | -| **AI** | 🔄 진행중 | OpenAI 기반 상품 추천 | 중간 | +| **AI** | ✅ 완료 | OpenAI 기반 상품 추천 | 높음 | --- @@ -242,6 +160,7 @@ src/main/java/com/cMall/feedShop/ | **MySQL 8.0** | 메인 데이터베이스 | | **H2** | 테스트용 인메모리 DB | | **Google Cloud Storage** | 파일 저장소 | +| **Google Cloud SQL** | 클라우드 데이터베이스 | ### DevOps & Quality @@ -259,7 +178,6 @@ src/main/java/com/cMall/feedShop/ | -------------------- | --------------------- | | **Mailgun** | 이메일 발송 | | **Google reCAPTCHA** | 봇 방지 | -| **Google Cloud SQL** | 클라우드 데이터베이스 | | **OpenAI API** | AI 상품 추천 | | **Google OAuth2** | 소셜 로그인 | | **Kakao OAuth2** | 소셜 로그인 | @@ -277,14 +195,8 @@ src/main/java/com/cMall/feedShop/ ### 빠른 시작 -1. **레포지토리 클론** - ```bash - git clone https://github.com/ECommerceCommunity/FeedShop_Backend.git - cd FeedShop_Backend - ``` - -2. **환경 설정** +1. **환경 설정** ```bash # application.properties.example을 복사하여 설정 파일 생성 @@ -301,13 +213,13 @@ src/main/java/com/cMall/feedShop/ export KAKAO_CLIENT_SECRET=your_kakao_client_secret ``` -3. **데이터베이스 설정** +2. **데이터베이스 설정** ```sql CREATE DATABASE feedshop_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ``` -4. **애플리케이션 실행** +3. **애플리케이션 실행** ```bash # 개발 환경 @@ -421,14 +333,15 @@ logging: ### GitHub Actions 워크플로우 -1. **CI Pipeline** (`.github/workflows/ci.yml`) +**CI Pipeline** (`.github/workflows/ci.yml`) - - Pull Request 시 자동 실행 - - 빌드, 테스트, 코드 분석 수행 - - SonarCloud 품질 게이트 검증 +- Pull Request 시 자동 실행 +- 빌드, 테스트, 코드 분석 수행 +- SonarCloud 품질 게이트 검증 + ci build + +test -2. **Jira 연동** (`.github/workflows/create-jira-issue.yml`) - - GitHub 이슈 생성 시 Jira 태스크 자동 생성 ### 배포 환경 @@ -439,10 +352,12 @@ logging: ### 모니터링 - **애플리케이션 메트릭**: Spring Boot Actuator -- **로그 관리**: 구조화된 로깅 -- **성능 모니터링**: APM 도구 연동 (구현 예정) -- **시각화 대시보드**: Grafana (구현 예정) -- **클라우드 모니터링**: Google Cloud Monitoring (구현 예정) +- **로그 관리**: 구조화된 로깅(Google Cloud Logging) +- **시각화 대시보드**: Grafana +- **클라우드 모니터링**: Google Cloud Monitoring + image + + --- @@ -459,12 +374,12 @@ logging: ### 커밋 메시지 규칙 ``` -type(scope): description +MYCE-001 type/scope: description -feat(user): 사용자 회원가입 기능 추가 -fix(order): 주문 생성 시 재고 검증 버그 수정 -refactor(product): 상품 조회 로직 개선 -docs(readme): API 문서 업데이트 +feat/user: 사용자 회원가입 기능 추가 +fix/order: 주문 생성 시 재고 검증 버그 수정 +refactor/product: 상품 조회 로직 개선 +docsreadme: API 문서 업데이트 ``` ### 코드 리뷰 체크리스트 @@ -477,24 +392,10 @@ docs(readme): API 문서 업데이트 --- -## 📝 라이선스 - -이 프로젝트는 **MIT 라이선스**를 따릅니다. 자세한 내용은 [LICENSE](LICENSE) 파일을 참고하세요. - ---- - -## 📞 문의 및 지원 - -- **이슈 리포트**: [GitHub Issues](https://github.com/ECommerceCommunity/FeedShop_Backend/issues) -- **기술 문서**: [Wiki](https://github.com/ECommerceCommunity/FeedShop_Backend/wiki) -- **개발자 가이드**: [개발 가이드 문서](docs/DEVELOPMENT.md) - ---- -
**FeedShop Backend Team** 🚀 _현대적인 이커머스 플랫폼을 위한 안정적이고 확장 가능한 백엔드 시스템_ -
+ \ No newline at end of file diff --git a/src/main/java/com/cMall/feedShop/config/DataInitializer.java b/src/main/java/com/cMall/feedShop/config/DataInitializer.java index a440aad0..81f893bd 100644 --- a/src/main/java/com/cMall/feedShop/config/DataInitializer.java +++ b/src/main/java/com/cMall/feedShop/config/DataInitializer.java @@ -20,6 +20,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.UUID; /** * 애플리케이션 시작 시 초기 데이터를 설정하는 클래스 @@ -216,11 +217,12 @@ private void createTestUser(String email, String loginId, UserRole role) { return; } + String newPassword = UUID.randomUUID().toString(); + try { - // 테스트 사용자 생성 - PasswordEncoder를 사용하여 비밀번호 암호화 User testUser = new User( loginId, - passwordEncoder.encode("password123!"), // 실제 암호화 + passwordEncoder.encode(newPassword), email, role ); diff --git a/src/main/java/com/cMall/feedShop/user/application/dto/request/EmailRequest.java b/src/main/java/com/cMall/feedShop/user/application/dto/request/EmailRequest.java index 3fbd6018..a202561f 100644 --- a/src/main/java/com/cMall/feedShop/user/application/dto/request/EmailRequest.java +++ b/src/main/java/com/cMall/feedShop/user/application/dto/request/EmailRequest.java @@ -8,4 +8,8 @@ public class EmailRequest { @Schema(description = "이메일 주소", example = "user@example.com", required = true) private String email; + + public void setEmail(String email) { + this.email = email; + } } diff --git a/src/main/java/com/cMall/feedShop/user/application/service/MfaServiceImpl.java b/src/main/java/com/cMall/feedShop/user/application/service/MfaServiceImpl.java index 0e30d002..8f9a9882 100644 --- a/src/main/java/com/cMall/feedShop/user/application/service/MfaServiceImpl.java +++ b/src/main/java/com/cMall/feedShop/user/application/service/MfaServiceImpl.java @@ -284,7 +284,43 @@ public boolean verifyBackupCode(String email, String backupCode) { @Transactional(propagation = Propagation.REQUIRES_NEW) public boolean verifyBackupCodeInNewTransaction(String email, String backupCode) { - return verifyBackupCode(email, backupCode); + String maskedEmail = LogMaskingUtil.maskEmail(email); + String maskedBackupCode = LogMaskingUtil.maskBackupCode(backupCode); + + try { + UserMfa userMfa = findUserMfaByEmail(email); + + if (userMfa.getBackupCodes() == null) { + log.warn("백업 코드가 설정되지 않음 - 사용자: {}", maskedEmail); + return false; + } + + List backupCodes = objectMapper.readValue( + userMfa.getBackupCodes(), + objectMapper.getTypeFactory().constructCollectionType(List.class, String.class) + ); + + if (backupCodes.contains(backupCode)) { + // 사용된 백업 코드 제거 + backupCodes.remove(backupCode); + userMfa.setBackupCodes(objectMapper.writeValueAsString(backupCodes)); + userMfaRepository.save(userMfa); + + log.info("백업 코드 인증 성공 (새 트랜잭션) - 사용자: {}, 남은 백업 코드 수: {}", maskedEmail, backupCodes.size()); + return true; + } + + log.warn("백업 코드 인증 실패 - 잘못된 코드 - 사용자: {}, 코드: {}", maskedEmail, maskedBackupCode); + return false; + + } catch (MfaException e) { + log.warn("백업 코드 검증 실패 - 사용자: {}, 코드: {}, 오류: {}", maskedEmail, maskedBackupCode, e.getMessage()); + return false; + } catch (Exception e) { + log.error("백업 코드 검증 중 예상치 못한 오류 발생 - 사용자: {}, 코드: {}, 오류: {}", + maskedEmail, maskedBackupCode, e.getMessage()); + return false; + } } // =========================== Private Helper Methods =========================== diff --git a/src/main/java/com/cMall/feedShop/user/infrastructure/oauth/CustomOAuth2UserService.java b/src/main/java/com/cMall/feedShop/user/infrastructure/oauth/CustomOAuth2UserService.java index 6e8a78ce..d71cdb1f 100644 --- a/src/main/java/com/cMall/feedShop/user/infrastructure/oauth/CustomOAuth2UserService.java +++ b/src/main/java/com/cMall/feedShop/user/infrastructure/oauth/CustomOAuth2UserService.java @@ -43,7 +43,35 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic @Transactional(propagation = Propagation.REQUIRES_NEW) public OAuth2User processAndSaveOAuth2UserInNewTransaction(OAuth2UserRequest userRequest, OAuth2User oAuth2User) { - return processAndSaveOAuth2User(userRequest, oAuth2User); + // 1. Get user info (same as before) + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, oAuth2User.getAttributes()); + + String email = oAuth2UserInfo.getEmail(); + if (email == null || email.isEmpty()) { + throw new OAuth2AuthenticationException("소셜 로그인 제공자에서 이메일을 가져올 수 없습니다."); + } + + // 2. Find or create user (move your existing logic here) + UserSocialProvider socialProvider = socialProviderRepository + .findByProviderAndProviderSocialUserId(registrationId, oAuth2UserInfo.getId()) + .orElse(null); + + User user; + if (socialProvider != null) { + user = handleExistingSocialUser(socialProvider, oAuth2UserInfo); + } else { + user = handleNewSocialUser(oAuth2UserInfo, registrationId); + } + + // 3. Return a CustomOAuth2User object (same as before) + return new CustomOAuth2User( + oAuth2User, + registrationId, + oAuth2UserInfo.getId(), + oAuth2UserInfo.getEmail(), + oAuth2UserInfo.getName() + ); } @Transactional diff --git a/src/test/java/com/cMall/feedShop/ai/application/service/ProductRecommendationServiceTest.java b/src/test/java/com/cMall/feedShop/ai/application/service/ProductRecommendationServiceTest.java index fa9c9b68..b68310b3 100644 --- a/src/test/java/com/cMall/feedShop/ai/application/service/ProductRecommendationServiceTest.java +++ b/src/test/java/com/cMall/feedShop/ai/application/service/ProductRecommendationServiceTest.java @@ -370,6 +370,7 @@ private ProductRecommendationAIResponse createMockResponse(List productIds // when List result = service.recommendProducts(user, prompt, 3); + // then assertThat(result).isNotNull(); assertThat(result.size()).isLessThanOrEqualTo(3); diff --git a/src/test/java/com/cMall/feedShop/common/util/LogMaskingUtilTest.java b/src/test/java/com/cMall/feedShop/common/util/LogMaskingUtilTest.java new file mode 100644 index 00000000..e6f06d22 --- /dev/null +++ b/src/test/java/com/cMall/feedShop/common/util/LogMaskingUtilTest.java @@ -0,0 +1,387 @@ +package com.cMall.feedShop.common.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("LogMaskingUtil 테스트") +class LogMaskingUtilTest { + + @Test + @DisplayName("이메일 마스킹 - 정상적인 이메일") + void maskEmail_NormalEmail() { + // given + String email = "user@example.com"; + + // when + String result = LogMaskingUtil.maskEmail(email); + + // then + assertThat(result).isEqualTo("u**r@example.com"); + } + + @Test + @DisplayName("이메일 마스킹 - 짧은 로컬 파트") + void maskEmail_ShortLocalPart() { + // given + String email = "ab@example.com"; + + // when + String result = LogMaskingUtil.maskEmail(email); + + // then + assertThat(result).isEqualTo("a*@example.com"); + } + + @Test + @DisplayName("이메일 마스킹 - 매우 짧은 로컬 파트") + void maskEmail_VeryShortLocalPart() { + // given + String email = "a@example.com"; + + // when + String result = LogMaskingUtil.maskEmail(email); + + // then + assertThat(result).isEqualTo("a*@example.com"); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"invalid-email", "no-at-sign"}) + @DisplayName("이메일 마스킹 - 잘못된 이메일 형식") + void maskEmail_InvalidEmail(String email) { + // when + String result = LogMaskingUtil.maskEmail(email); + + // then + assertThat(result).isEqualTo(email); + } + + @Test + @DisplayName("전화번호 마스킹 - 11자리 전화번호") + void maskPhoneNumber_11Digits() { + // given + String phoneNumber = "010-1234-5678"; + + // when + String result = LogMaskingUtil.maskPhoneNumber(phoneNumber); + + // then + assertThat(result).isEqualTo("***-****-5678"); + } + + @Test + @DisplayName("전화번호 마스킹 - 10자리 전화번호") + void maskPhoneNumber_10Digits() { + // given + String phoneNumber = "02-123-4567"; + + // when + String result = LogMaskingUtil.maskPhoneNumber(phoneNumber); + + // then + assertThat(result).isEqualTo("*****4567"); + } + + @Test + @DisplayName("전화번호 마스킹 - 하이픈 없는 전화번호") + void maskPhoneNumber_NoHyphens() { + // given + String phoneNumber = "01012345678"; + + // when + String result = LogMaskingUtil.maskPhoneNumber(phoneNumber); + + // then + assertThat(result).isEqualTo("*******5678"); + } + + @Test + @DisplayName("전화번호 마스킹 - 짧은 전화번호") + void maskPhoneNumber_ShortNumber() { + // given + String phoneNumber = "123"; + + // when + String result = LogMaskingUtil.maskPhoneNumber(phoneNumber); + + // then + assertThat(result).isEqualTo("123"); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"123", "abc"}) + @DisplayName("전화번호 마스킹 - 잘못된 전화번호") + void maskPhoneNumber_InvalidPhoneNumber(String phoneNumber) { + // when + String result = LogMaskingUtil.maskPhoneNumber(phoneNumber); + + // then + assertThat(result).isEqualTo(phoneNumber); + } + + @Test + @DisplayName("토큰 마스킹 - 긴 토큰") + void maskToken_LongToken() { + // given + String token = "abc123def456ghi789"; + + // when + String result = LogMaskingUtil.maskToken(token); + + // then + assertThat(result).isEqualTo("abc************789"); + } + + @Test + @DisplayName("토큰 마스킹 - 중간 길이 토큰") + void maskToken_MediumToken() { + // given + String token = "abc123def"; + + // when + String result = LogMaskingUtil.maskToken(token); + + // then + assertThat(result).isEqualTo("abc***def"); + } + + @Test + @DisplayName("토큰 마스킹 - 짧은 토큰") + void maskToken_ShortToken() { + // given + String token = "abc123"; + + // when + String result = LogMaskingUtil.maskToken(token); + + // then + assertThat(result).isEqualTo("ab**23"); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"123", "ab", "a"}) + @DisplayName("토큰 마스킹 - 잘못된 토큰") + void maskToken_InvalidToken(String token) { + // when + String result = LogMaskingUtil.maskToken(token); + + // then + assertThat(result).isEqualTo(token); + } + + @Test + @DisplayName("MFA 토큰 마스킹 - 정상적인 6자리 토큰") + void maskMfaToken_ValidToken() { + // given + String token = "123456"; + + // when + String result = LogMaskingUtil.maskMfaToken(token); + + // then + assertThat(result).isEqualTo("12****"); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"12345", "1234567", "12", "123"}) + @DisplayName("MFA 토큰 마스킹 - 잘못된 토큰") + void maskMfaToken_InvalidToken(String token) { + // when + String result = LogMaskingUtil.maskMfaToken(token); + + // then + assertThat(result).isEqualTo(token); + } + + @Test + @DisplayName("백업 코드 마스킹 - 정상적인 8자리 코드") + void maskBackupCode_ValidCode() { + // given + String code = "12345678"; + + // when + String result = LogMaskingUtil.maskBackupCode(code); + + // then + assertThat(result).isEqualTo("12****78"); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"1234567", "123456789", "12", "123"}) + @DisplayName("백업 코드 마스킹 - 잘못된 코드") + void maskBackupCode_InvalidCode(String code) { + // when + String result = LogMaskingUtil.maskBackupCode(code); + + // then + assertThat(result).isEqualTo(code); + } + + @Test + @DisplayName("사용자 ID 마스킹 - 긴 ID") + void maskUserId_LongId() { + // given + Long userId = 12345L; + + // when + String result = LogMaskingUtil.maskUserId(userId); + + // then + assertThat(result).isEqualTo("1****"); + } + + @Test + @DisplayName("사용자 ID 마스킹 - 짧은 ID") + void maskUserId_ShortId() { + // given + Long userId = 123L; + + // when + String result = LogMaskingUtil.maskUserId(userId); + + // then + assertThat(result).isEqualTo("1**"); + } + + @Test + @DisplayName("사용자 ID 마스킹 - 한 자리 ID") + void maskUserId_SingleDigitId() { + // given + Long userId = 5L; + + // when + String result = LogMaskingUtil.maskUserId(userId); + + // then + assertThat(result).isEqualTo("5"); + } + + @Test + @DisplayName("사용자 ID 마스킹 - null ID") + void maskUserId_NullId() { + // when + String result = LogMaskingUtil.maskUserId(null); + + // then + assertThat(result).isNull(); + } + + @ParameterizedTest + @CsvSource({ + "user@example.com, email, u**r@example.com", + "010-1234-5678, phone, ***-****-5678", + "abc123def456, token, abc******456", + "123456, mfa, 12****", + "12345678, backup, 12****78", + "unknown, unknown, unknown" + }) + @DisplayName("민감 정보 마스킹 - 타입별 마스킹") + void maskSensitiveInfo_ByType(String input, String type, String expected) { + // when + String result = LogMaskingUtil.maskSensitiveInfo(input, type); + + // then + assertThat(result).isEqualTo(expected); + } + + @Test + @DisplayName("민감 정보 마스킹 - null 입력") + void maskSensitiveInfo_NullInput() { + // when + String result = LogMaskingUtil.maskSensitiveInfo(null, "email"); + + // then + assertThat(result).isNull(); + } + + @Test + @DisplayName("민감 정보 마스킹 - 대소문자 구분 없는 타입") + void maskSensitiveInfo_CaseInsensitiveType() { + // given + String email = "user@example.com"; + + // when + String result1 = LogMaskingUtil.maskSensitiveInfo(email, "EMAIL"); + String result2 = LogMaskingUtil.maskSensitiveInfo(email, "Email"); + + // then + assertThat(result1).isEqualTo("u**r@example.com"); + assertThat(result2).isEqualTo("u**r@example.com"); + } + + @Test + @DisplayName("이메일 마스킹 - 다양한 이메일 형식") + void maskEmail_VariousFormats() { + // given & when & then + assertThat(LogMaskingUtil.maskEmail("test@example.com")).isEqualTo("t**t@example.com"); + assertThat(LogMaskingUtil.maskEmail("admin@company.co.kr")).isEqualTo("a***n@company.co.kr"); + assertThat(LogMaskingUtil.maskEmail("user123@domain.org")).isEqualTo("u*****3@domain.org"); + assertThat(LogMaskingUtil.maskEmail("a@b.c")).isEqualTo("a*@b.c"); + assertThat(LogMaskingUtil.maskEmail("ab@c.d")).isEqualTo("a*@c.d"); + } + + @Test + @DisplayName("전화번호 마스킹 - 다양한 전화번호 형식") + void maskPhoneNumber_VariousFormats() { + // given & when & then + assertThat(LogMaskingUtil.maskPhoneNumber("010-1234-5678")).isEqualTo("***-****-5678"); + assertThat(LogMaskingUtil.maskPhoneNumber("02-123-4567")).isEqualTo("*****4567"); + assertThat(LogMaskingUtil.maskPhoneNumber("031-123-4567")).isEqualTo("***-***-4567"); + assertThat(LogMaskingUtil.maskPhoneNumber("01012345678")).isEqualTo("*******5678"); + assertThat(LogMaskingUtil.maskPhoneNumber("0212345678")).isEqualTo("******5678"); + } + + @Test + @DisplayName("토큰 마스킹 - 다양한 토큰 길이") + void maskToken_VariousLengths() { + // given & when & then + assertThat(LogMaskingUtil.maskToken("abc123")).isEqualTo("ab**23"); // 6자리 + assertThat(LogMaskingUtil.maskToken("abc123def")).isEqualTo("abc***def"); // 9자리 + assertThat(LogMaskingUtil.maskToken("abc123def456")).isEqualTo("abc******456"); // 12자리 + assertThat(LogMaskingUtil.maskToken("abc123def456ghi789")).isEqualTo("abc************789"); // 18자리 + } + + @Test + @DisplayName("사용자 ID 마스킹 - 다양한 ID 길이") + void maskUserId_VariousLengths() { + // given & when & then + assertThat(LogMaskingUtil.maskUserId(1L)).isEqualTo("1"); + assertThat(LogMaskingUtil.maskUserId(12L)).isEqualTo("1*"); + assertThat(LogMaskingUtil.maskUserId(123L)).isEqualTo("1**"); + assertThat(LogMaskingUtil.maskUserId(1234L)).isEqualTo("1***"); + assertThat(LogMaskingUtil.maskUserId(12345L)).isEqualTo("1****"); + assertThat(LogMaskingUtil.maskUserId(123456L)).isEqualTo("1*****"); + } + + @Test + @DisplayName("엣지 케이스 - 빈 문자열과 공백") + void edgeCases_EmptyAndWhitespace() { + // given & when & then + assertThat(LogMaskingUtil.maskEmail("")).isEqualTo(""); + assertThat(LogMaskingUtil.maskEmail(" ")).isEqualTo(" "); + assertThat(LogMaskingUtil.maskPhoneNumber("")).isEqualTo(""); + assertThat(LogMaskingUtil.maskPhoneNumber(" ")).isEqualTo(" "); + assertThat(LogMaskingUtil.maskToken("")).isEqualTo(""); + assertThat(LogMaskingUtil.maskToken(" ")).isEqualTo(" "); + } + + @Test + @DisplayName("엣지 케이스 - 특수 문자 포함") + void edgeCases_SpecialCharacters() { + // given & when & then + assertThat(LogMaskingUtil.maskEmail("user+tag@example.com")).isEqualTo("u******g@example.com"); + assertThat(LogMaskingUtil.maskPhoneNumber("010-1234-5678 (mobile)")).isEqualTo("***-****-5678"); + assertThat(LogMaskingUtil.maskToken("abc-123_def.456")).isEqualTo("abc*********456"); + } +} diff --git a/src/test/java/com/cMall/feedShop/user/presentation/UserAuthControllerTest.java b/src/test/java/com/cMall/feedShop/user/presentation/UserAuthControllerTest.java new file mode 100644 index 00000000..3ded6ddc --- /dev/null +++ b/src/test/java/com/cMall/feedShop/user/presentation/UserAuthControllerTest.java @@ -0,0 +1,439 @@ +package com.cMall.feedShop.user.presentation; + +import com.cMall.feedShop.common.captcha.RecaptchaVerificationService; +import com.cMall.feedShop.common.dto.ApiResponse; +import com.cMall.feedShop.common.exception.BusinessException; +import com.cMall.feedShop.common.exception.ErrorCode; +import com.cMall.feedShop.common.exception.GlobalExceptionHandler; +import com.cMall.feedShop.user.application.dto.request.EmailRequest; +import com.cMall.feedShop.user.application.dto.request.PasswordResetConfirmRequest; +import com.cMall.feedShop.user.application.dto.request.UserLoginRequest; +import com.cMall.feedShop.user.application.dto.request.UserSignUpRequest; +import com.cMall.feedShop.user.application.dto.response.MfaStatusResponse; +import com.cMall.feedShop.user.application.dto.response.UserLoginResponse; +import com.cMall.feedShop.user.application.dto.response.UserResponse; +import com.cMall.feedShop.user.application.service.MfaService; +import com.cMall.feedShop.user.application.service.UserAuthService; +import com.cMall.feedShop.user.application.service.UserService; +import com.cMall.feedShop.user.domain.enums.UserRole; +import com.fasterxml.jackson.databind.ObjectMapper; +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.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("UserAuthController 테스트") +class UserAuthControllerTest { + + @Mock + private UserService userService; + + @Mock + private UserAuthService userAuthService; + + @Mock + private RecaptchaVerificationService recaptchaService; + + @Mock + private MfaService mfaService; + + @InjectMocks + private UserAuthController userAuthController; + + private MockMvc mockMvc; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(userAuthController) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + objectMapper = new ObjectMapper(); + } + + @Test + @DisplayName("회원가입 - 성공") + void signUp_Success() throws Exception { + // given + UserSignUpRequest request = new UserSignUpRequest(); + request.setEmail("test@example.com"); + request.setPassword("password123!"); + request.setConfirmPassword("password123!"); + request.setName("테스트 사용자"); + request.setPhone("010-1234-5678"); + + UserResponse expectedResponse = UserResponse.builder() + .userId(1L) + .email("test@example.com") + .username("테스트 사용자") + .role(UserRole.USER) + .build(); + + given(userService.signUp(any(UserSignUpRequest.class))) + .willReturn(expectedResponse); + + // when & then + mockMvc.perform(post("/api/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.userId").value(1)) + .andExpect(jsonPath("$.data.email").value("test@example.com")) + .andExpect(jsonPath("$.data.username").value("테스트 사용자")); + + verify(userService, times(1)).signUp(any(UserSignUpRequest.class)); + } + + @Test + @DisplayName("회원가입 - 실패 (잘못된 요청)") + void signUp_Failure_InvalidRequest() throws Exception { + // given + UserSignUpRequest request = new UserSignUpRequest(); + // 필수 필드 누락 + + // when & then + mockMvc.perform(post("/api/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + + verify(userService, never()).signUp(any(UserSignUpRequest.class)); + } + + @Test + @DisplayName("로그인 - 성공 (MFA 비활성화)") + void login_Success_WithoutMfa() throws Exception { + // given + UserLoginRequest request = new UserLoginRequest(); + request.setEmail("test@example.com"); + request.setPassword("password123"); + request.setRecaptchaToken("valid-token"); + + UserLoginResponse loginResponse = UserLoginResponse.builder() + .loginId("testuser") + .role(UserRole.USER) + .nickname("테스트") + .token("jwt-token") + .build(); + + MfaStatusResponse mfaStatus = MfaStatusResponse.builder() + .enabled(false) + .email("test@example.com") + .build(); + + doNothing().when(recaptchaService).verifyRecaptcha(anyString(), anyString()); + given(userAuthService.login(any(UserLoginRequest.class))) + .willReturn(loginResponse); + given(mfaService.getMfaStatus(anyString())) + .willReturn(mfaStatus); + + // when & then + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.loginId").value("testuser")) + .andExpect(jsonPath("$.data.token").value("jwt-token")) + .andExpect(jsonPath("$.data.requiresMfa").value(false)); + + verify(recaptchaService, times(1)).verifyRecaptcha(anyString(), anyString()); + verify(userAuthService, times(1)).login(any(UserLoginRequest.class)); + verify(mfaService, times(1)).getMfaStatus(anyString()); + } + + @Test + @DisplayName("로그인 - 성공 (MFA 활성화)") + void login_Success_WithMfa() throws Exception { + // given + UserLoginRequest request = new UserLoginRequest(); + request.setEmail("test@example.com"); + request.setPassword("password123"); + request.setRecaptchaToken("valid-token"); + + UserLoginResponse loginResponse = UserLoginResponse.builder() + .loginId("testuser") + .role(UserRole.USER) + .nickname("테스트") + .token("jwt-token") + .build(); + + MfaStatusResponse mfaStatus = MfaStatusResponse.builder() + .enabled(true) + .email("test@example.com") + .build(); + + doNothing().when(recaptchaService).verifyRecaptcha(anyString(), anyString()); + given(userAuthService.login(any(UserLoginRequest.class))) + .willReturn(loginResponse); + given(mfaService.getMfaStatus(anyString())) + .willReturn(mfaStatus); + + // when & then + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.loginId").value("testuser")) + .andExpect(jsonPath("$.data.requiresMfa").value(true)) + .andExpect(jsonPath("$.data.tempToken").value("jwt-token")); + + verify(recaptchaService, times(1)).verifyRecaptcha(anyString(), anyString()); + verify(userAuthService, times(1)).login(any(UserLoginRequest.class)); + verify(mfaService, times(1)).getMfaStatus(anyString()); + } + + @Test + @DisplayName("로그인 - 실패 (reCAPTCHA 검증 실패)") + void login_Failure_RecaptchaVerificationFailed() throws Exception { + // given + UserLoginRequest request = new UserLoginRequest(); + request.setEmail("test@example.com"); + request.setPassword("password123"); + request.setRecaptchaToken("invalid-token"); + + doThrow(new BusinessException(ErrorCode.RECAPTCHA_VERIFICATION_FAILED)) + .when(recaptchaService).verifyRecaptcha(anyString(), anyString()); + + // when & then + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + + verify(recaptchaService, times(1)).verifyRecaptcha(anyString(), anyString()); + verify(userAuthService, never()).login(any(UserLoginRequest.class)); + } + + @Test + @DisplayName("이메일 인증 - 성공") + void verifyEmail_Success() throws Exception { + // given + String token = "valid-email-token"; + doNothing().when(userService).verifyEmail(anyString()); + + // when & then + mockMvc.perform(get("/api/auth/verify-email") + .param("token", token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").value("이메일 인증이 완료되었습니다. 이제 로그인할 수 있습니다.")); + + verify(userService, times(1)).verifyEmail(token); + } + + @Test + @DisplayName("이메일 인증 - 실패 (잘못된 토큰)") + void verifyEmail_Failure_InvalidToken() throws Exception { + // given + String token = "invalid-token"; + doThrow(new BusinessException(ErrorCode.INVALID_VERIFICATION_TOKEN)) + .when(userService).verifyEmail(anyString()); + + // when & then + mockMvc.perform(get("/api/auth/verify-email") + .param("token", token)) + .andExpect(status().isBadRequest()); + + verify(userService, times(1)).verifyEmail(token); + } + + @Test + @DisplayName("계정 찾기 - 성공") + void findAccountByNameAndPhone_Success() throws Exception { + // given + String username = "홍길동"; + String phoneNumber = "010-1234-5678"; + + List expectedAccounts = Arrays.asList( + UserResponse.builder() + .userId(1L) + .email("h***@example.com") + .username("홍길동") + .role(UserRole.USER) + .build() + ); + + given(userService.findByUsernameAndPhoneNumber(anyString(), anyString())) + .willReturn(expectedAccounts); + + // when & then + mockMvc.perform(get("/api/auth/find-account") + .param("username", username) + .param("phoneNumber", phoneNumber)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data[0].userId").value(1)) + .andExpect(jsonPath("$.data[0].email").value("h***@example.com")) + .andExpect(jsonPath("$.data[0].username").value("홍길동")); + + verify(userService, times(1)).findByUsernameAndPhoneNumber(username, phoneNumber); + } + + @Test + @DisplayName("계정 찾기 - 실패 (계정 없음)") + void findAccountByNameAndPhone_Failure_AccountNotFound() throws Exception { + // given + String username = "존재하지않는사용자"; + String phoneNumber = "010-9999-9999"; + + given(userService.findByUsernameAndPhoneNumber(anyString(), anyString())) + .willThrow(new BusinessException(ErrorCode.USER_NOT_FOUND)); + + // when & then + mockMvc.perform(get("/api/auth/find-account") + .param("username", username) + .param("phoneNumber", phoneNumber)) + .andExpect(status().isNotFound()); + + verify(userService, times(1)).findByUsernameAndPhoneNumber(username, phoneNumber); + } + + @Test + @DisplayName("비밀번호 재설정 요청 - 성공") + void forgotPassword_Success() throws Exception { + // given + EmailRequest request = new EmailRequest(); + request.setEmail("test@example.com"); + + doNothing().when(userAuthService).requestPasswordReset(anyString()); + + // when & then + mockMvc.perform(post("/api/auth/forgot-password") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").value("비밀번호 재설정 이메일이 발송되었습니다.")); + + verify(userAuthService, times(1)).requestPasswordReset("test@example.com"); + } + + @Test + @DisplayName("비밀번호 재설정 요청 - 실패 (존재하지 않는 이메일)") + void forgotPassword_Failure_EmailNotFound() throws Exception { + // given + EmailRequest request = new EmailRequest(); + request.setEmail("nonexistent@example.com"); + + doThrow(new BusinessException(ErrorCode.USER_NOT_FOUND)) + .when(userAuthService).requestPasswordReset(anyString()); + + // when & then + mockMvc.perform(post("/api/auth/forgot-password") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()); + + verify(userAuthService, times(1)).requestPasswordReset("nonexistent@example.com"); + } + + @Test + @DisplayName("비밀번호 재설정 토큰 검증 - 성공") + void validatePasswordResetToken_Success() throws Exception { + // given + String token = "valid-reset-token"; + doNothing().when(userAuthService).validatePasswordResetToken(anyString()); + + // when & then + mockMvc.perform(get("/api/auth/reset-password/validate") + .param("token", token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").value("토큰이 유효합니다.")); + + verify(userAuthService, times(1)).validatePasswordResetToken(token); + } + + @Test + @DisplayName("비밀번호 재설정 토큰 검증 - 실패 (잘못된 토큰)") + void validatePasswordResetToken_Failure_InvalidToken() throws Exception { + // given + String token = "invalid-reset-token"; + doThrow(new BusinessException(ErrorCode.INVALID_TOKEN)) + .when(userAuthService).validatePasswordResetToken(anyString()); + + // when & then + mockMvc.perform(get("/api/auth/reset-password/validate") + .param("token", token)) + .andExpect(status().isBadRequest()); + + verify(userAuthService, times(1)).validatePasswordResetToken(token); + } + + @Test + @DisplayName("비밀번호 재설정 - 성공") + void resetPassword_Success() throws Exception { + // given + PasswordResetConfirmRequest request = new PasswordResetConfirmRequest(); + request.setToken("valid-reset-token"); + request.setNewPassword("newPassword123"); + + doNothing().when(userAuthService).resetPassword(anyString(), anyString()); + + // when & then + mockMvc.perform(post("/api/auth/reset-password") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").value("비밀번호가 성공적으로 재설정되었습니다.")); + + verify(userAuthService, times(1)).resetPassword("valid-reset-token", "newPassword123"); + } + + @Test + @DisplayName("비밀번호 재설정 - 실패 (잘못된 토큰)") + void resetPassword_Failure_InvalidToken() throws Exception { + // given + PasswordResetConfirmRequest request = new PasswordResetConfirmRequest(); + request.setToken("invalid-reset-token"); + request.setNewPassword("newPassword123"); + + doThrow(new BusinessException(ErrorCode.INVALID_TOKEN)) + .when(userAuthService).resetPassword(anyString(), anyString()); + + // when & then + mockMvc.perform(post("/api/auth/reset-password") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + + verify(userAuthService, times(1)).resetPassword("invalid-reset-token", "newPassword123"); + } + + @Test + @DisplayName("비밀번호 재설정 - 실패 (잘못된 요청)") + void resetPassword_Failure_InvalidRequest() throws Exception { + // given + PasswordResetConfirmRequest request = new PasswordResetConfirmRequest(); + // 필수 필드 누락 + + // when & then + mockMvc.perform(post("/api/auth/reset-password") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + + verify(userAuthService, never()).resetPassword(anyString(), anyString()); + } +}