From f7531412be49753d601623c8da8116c39e61fb36 Mon Sep 17 00:00:00 2001 From: "Rinjae(gf63)" Date: Tue, 15 Sep 2026 15:33:41 +0900 Subject: [PATCH] =?UTF-8?q?-=20=EC=95=BD=EA=B4=80=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC/=EC=84=9C=EB=B9=84=EC=8A=A4=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80:=20=EB=8F=99=EC=9D=98?= =?UTF-8?q?=EC=84=9C=20=EB=85=B8=EC=B6=9C=20=EB=B0=8F=20=EB=8F=99=EC=9E=91?= =?UTF-8?q?=20=EB=B0=A9=EC=8B=9D=20=EA=B2=80=EC=A6=9D=20-=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EB=93=B1=EB=A1=9D=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8:=20=EC=95=BD?= =?UTF-8?q?=EA=B4=80=20=EB=85=B8=EC=B6=9C=20=ED=95=AD=EB=AA=A9=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B0=98=EC=98=81=20=ED=99=95=EC=9D=B8=20-=20?= =?UTF-8?q?=EB=B2=95=EC=9D=B8=20=EA=B0=80=EC=9E=85/=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EA=B4=80=EB=A6=AC=20=EA=B4=80=EB=A0=A8=20=EC=95=BD?= =?UTF-8?q?=EA=B4=80=20=EB=8F=99=EC=9E=91=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 316 +++++++++++------- Jenkinsfile.security | 22 +- Jenkinsfile.sonar | 21 +- build.gradle | 28 ++ sonar-project.properties | 17 + .../controller/AgreementsControllerTest.java | 240 +++++++++++++ .../AgreementFormModelSupportTest.java | 159 +++++++++ .../apps/user/AccountControllerTest.java | 62 ++++ .../apps/user/OrgRegisterControllerTest.java | 137 ++++++++ .../apps/user/UserManControllerTest.java | 99 ++++++ .../UserRegisterControllerAgreementTest.java | 242 ++++++++++++++ 11 files changed, 1221 insertions(+), 122 deletions(-) create mode 100644 src/test/java/com/eactive/apim/portal/apps/agreements/controller/AgreementsControllerTest.java create mode 100644 src/test/java/com/eactive/apim/portal/apps/agreements/service/AgreementFormModelSupportTest.java create mode 100644 src/test/java/com/eactive/apim/portal/apps/user/OrgRegisterControllerTest.java create mode 100644 src/test/java/com/eactive/apim/portal/apps/user/UserManControllerTest.java create mode 100644 src/test/java/com/eactive/apim/portal/apps/user/UserRegisterControllerAgreementTest.java diff --git a/CLAUDE.md b/CLAUDE.md index 913f081..dbd637c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 프로젝트 개요 -**EAPIM Portal**은 광주은행을 위한 엔터프라이즈 API 포털 관리 시스템입니다. API 서비스 관리, 사용자 등록, API 키 발급, 문서화, 테스트 기능을 제공하는 웹 기반 플랫폼입니다. +**EAPIM Portal**은 제주은행을 위한 엔터프라이즈 API 포털 관리 시스템입니다. API 서비스 관리, 사용자 등록, API 키 발급, 문서화, 테스트 기능을 제공하는 웹 기반 플랫폼입니다. **기술 스택:** - Spring Boot 2.7.18 with Spring MVC and Thymeleaf @@ -25,50 +25,52 @@ ## Git 브랜치 전략 **저장소 정보:** -- 기본 저장소: `ssh://git@192.168.240.178:18081/eapim/eapim-portal.git` -- 대체 저장소: `https://git.eactive.synology.me:8090/kjb-eapim/eapim-portal.git` +- origin: `ssh://git@172.30.1.50:2222/djb-eapim/eapim-portal.git` (Gitea, 사내망) +- 이 저장소가 유일한 remote다. Jenkins 파이프라인도 같은 주소를 본다. **브랜치:** -- **jenkins_with_weblogic**: Jenkins 빌드와 WebLogic 테스트를 위한 브랜치 (기본 개발 브랜치) - - Jenkins CI/CD 파이프라인 설정 포함 - - WebLogic 배포 및 테스트 환경 설정 - - 일상적인 개발 작업은 이 브랜치에서 수행 - - 기능 개발, 버그 수정, 테스트 등 모든 개발 활동의 기본 브랜치 +- **master**: 기본 개발 브랜치이자 배포 기준 브랜치 + - 일상적인 개발 작업(기능 개발, 버그 수정)을 여기서 직접 수행한다 + - Jenkins 4개 파이프라인(`Jenkinsfile.*`)이 모두 `origin/master` 를 체크아웃한다 + - `Jenkinsfile.security` 는 pollSCM 으로 master 변경을 감지해 자동 실행된다 -- **master**: 완벽히 동작하는 검증된 코드 저장소 (안정 브랜치) - - 프로덕션 배포 가능한 안정적인 코드만 포함 - - jenkins_with_weblogic 브랜치에서 충분히 테스트된 코드만 병합 - - 직접 커밋 금지, Pull Request를 통한 병합만 허용 +- **feats/\***, **design\***, **develop**: 과거 작업 잔여 브랜치 + - 현재 활성 개발에 사용하지 않는다. 참고용으로만 남아 있다 -**브랜치 사용 가이드:** +> 과거 문서에 있던 `jenkins_with_weblogic` 브랜치는 **존재하지 않는다**. +> Jenkins/WebLogic 설정은 master 의 `Jenkinsfile.deploy` 에 통합되어 있다. + +**작업 흐름:** ```bash -# 개발 시작 시 jenkins_with_weblogic 브랜치에서 작업 -git checkout jenkins_with_weblogic +# master 에서 바로 작업 +git checkout master +git pull origin master -# 기능 개발 후 커밋 +# 커밋 후 푸시 → Jenkins security 파이프라인이 자동 트리거됨 git add . git commit -m "기능 설명" - -# jenkins_with_weblogic 브랜치에 푸시 -git push origin jenkins_with_weblogic - -# 충분한 테스트 완료 후 master로 병합 (Pull Request 사용) +git push origin master ``` ## 빌드 명령어 ### 애플리케이션 실행 -- gradlew 사용 금지 - offline gradle 단독 실행 -- JDK/Gradle 환경 변수는 저장소 루트의 `.envrc`(direnv)로 자동 설정됨 (JAVA_HOME → Zulu JDK 8, GRADLE_USER_HOME → 격리 디렉터리) +- Linux 개발 환경에서는 `gradlew` 대신 설치된 `gradle`(8.7)을 직접 쓴다. Jenkins 노드도 `/apps/opts/gradle-8.7` 의 `gradle` 을 쓴다. +- Windows/Eclipse 환경 기동 절차는 `BOOTRUN_SETUP_GUIDE.md` 참고 (거기서는 `gradlew.bat bootRun` 사용). +- `.envrc`(direnv)는 **없다**. JAVA_HOME(JDK 8)/GRADLE_HOME 은 셸에서 직접 맞춰야 한다. ```bash -# dev 프로파일로 실행 (기본값) +# bootRun 기본 프로파일은 build.gradle 의 bootProfile 기본값(local_rinjaemac) gradle bootRun -# 특정 프로파일로 실행 -gradle bootRun --args='--spring.profiles.active=stage' +# 다른 프로파일로 기동 (--args 가 아니라 -PbootProfile) +gradle bootRun -PbootProfile=dev ``` +> `bootRun` 의 프로파일은 `-PbootProfile` 로 넘긴다. `-Pprofile` 은 `ext.profile='local'` +> 이 이미 점유하고 있어 무시된다(`build.gradle` 의 주석 참고). +> Jenkins 빌드에서 쓰는 `-Pprofile=weblogic` 은 스프링 프로파일이 아니라 빌드용 플래그다. + ### 빌드 ```bash @@ -82,6 +84,9 @@ gradle war # 클린 후 빌드 gradle clean build + +# SBOM(xlsx) 생성 - Jenkins 빌드 파이프라인에서 사용 +gradle sbomXlsx ``` ### 테스트 @@ -90,7 +95,7 @@ gradle clean build # 모든 테스트 실행 gradle test -# 커버리지와 함께 테스트 실행 +# 로그 상세 출력 gradle test --info # 특정 테스트 클래스 실행 @@ -100,24 +105,44 @@ gradle test --tests "com.eactive.apim.portal.apps.user.AccountControllerTest" gradle test --tests "*Controller*" ``` +> **커버리지는 측정되지 않는다.** JaCoCo 플러그인이 `build.gradle` 에 없고 +> `sonar-project.properties` 에도 `sonar.coverage.jacoco.xmlReportPaths` 가 없다. +> 그래서 SonarQube 의 Coverage 가 항상 0% 로 뜨고 Quality Gate 가 ERROR 가 된다 +> (Sonar 의 Zero Coverage Sensor 가 리포트 없는 라인을 전부 0 으로 채운다). + +### CSS/SASS 빌드 + +```bash +npm run sass:build # main.css (expanded) +npm run build # main.css + main.min.css +npm run sass:watch # 변경 감시 +./sass-build.sh # sass CLI 직접 호출 (npm 없이) +``` + ### 개발 ```bash -# 현재 프로파일 확인 +# 현재 프로파일 확인 (-Pprofile 로 넘긴 값) gradle printProfile # 소스 세트 설정 확인 gradle printSourceSets +``` -# Docker 이미지 빌드 -./build_docker.sh +### Docker + +`build_docker.sh` 는 **없다**. `Dockerfile` 만 있으므로 직접 빌드한다. + +```bash +gradle bootWar +docker build -t eapim-portal:latest . ``` ## 아키텍처 ### 멀티 모듈 구조 -이 프로젝트는 Gradle composite build를 통해 3개의 외부 모듈에 의존합니다: +이 프로젝트는 `settings.gradle` 의 멀티 프로젝트 구성으로 2개의 형제 디렉터리 모듈에 의존합니다: 1. **elink-online-core-jpa** (`../eapim-online/elink-online-core-jpa`) - Gateway 데이터 모델 및 JPA 엔티티 @@ -129,37 +154,53 @@ gradle printSourceSets - 기본 리포지토리 구현, QueryDSL 지원 - 공통 예외 핸들러 및 보안 유틸리티 -3. **kjb-safedb** (`../kjb-safedb`) - - 광주은행 SafeDB 암호화 라이브러리 - - 데이터베이스 레벨 민감 데이터 암호화 - - 암호화 컬럼(사용자 비밀번호, API 키 등)에 필수 +두 모듈은 Jenkins 파이프라인의 `Checkout dependencies` 스테이지가 같은 Gitea 서버에서 +자동으로 clone/reset 한다. 로컬에서도 `../eapim-online/elink-online-core-jpa` 와 +`../elink-portal-common` 이 없으면 컴파일 자체가 되지 않는다. + +**kjb-safedb (SafeDB 암호화 라이브러리)는 현재 빌드에서 빠져 있다.** +`build.gradle` 의 `implementation project(':kjb-safedb')` 가 주석 처리되어 있고 +`settings.gradle` 에도 등록되어 있지 않으며 `../kjb-safedb` 디렉터리도 없다. +다시 붙일 때는 세 곳을 모두 되살려야 한다. ### 패키지 구조 코드는 기술 계층이 아닌 **기능 모듈**(수직 분할) 방식으로 구성됩니다: ``` -com.eactive.apim.portal/ -├── apps/ # 기능 모듈 -│ ├── agreements/ # API 약관 -│ ├── apis/ # API 카탈로그 & 문서 -│ ├── apiservice/ # API 서비스 그룹핑 -│ ├── app/ # API 키 관리 -│ ├── approval/ # 승인 워크플로우 -│ ├── auth/ # 인증 -│ ├── community/ # FAQ, 공지사항, Q&A, 제휴문의 -│ ├── dashboard/ # 통계 & 분석 -│ ├── file/ # 파일 업로드/다운로드 -│ ├── login/ # 로그인/로그아웃 -│ ├── proxy/ # API 테스트용 Forward Proxy -│ ├── sample/ # 샘플 코드 생성 -│ └── user/ # 사용자 관리 -├── common/ # 공통 관심사 -├── config/ # Spring 설정 -└── gateway/ # Gateway DB 직접 접근 - └── data/ # Gateway 리포지토리 +com.eactive.apim/ +├── portal/ +│ ├── PortalApplication.java +│ ├── apps/ # 기능 모듈 +│ │ ├── HealthCheckController.java +│ │ ├── ReadinessController.java +│ │ ├── agreements/ # API 약관 +│ │ ├── apis/ # API 카탈로그 & 문서 +│ │ ├── apiservice/ # API 서비스 그룹핑 +│ │ ├── app/ # API 키 관리 +│ │ ├── approval/ # 승인 워크플로우 +│ │ ├── auth/ # 인증 +│ │ ├── community/ # FAQ, 공지사항, Q&A, 제휴문의 +│ │ ├── dashboard/ # 대시보드 +│ │ ├── file/ # 파일 업로드/다운로드 +│ │ ├── login/ # 로그인/로그아웃 +│ │ ├── main/ # 메인 화면 +│ │ ├── sample/ # 샘플 코드 생성 +│ │ ├── session/ # 세션 관리 (filter/entity/repository 포함) +│ │ ├── statistics/ # API 통계 +│ │ └── user/ # 사용자 관리 +│ ├── common/ # 공통 관심사 +│ ├── config/ # Spring 설정 +│ ├── custom/ # 사이트별 커스터마이징 설정 +│ ├── djb/ # 은행 특화 기능 +│ │ ├── apistatus/ community/ footer/ guide/ menu/ notitest/ +│ ├── spring/ # DatabaseSessionVerifier 등 +│ └── tools/ # HibernateSqlGenerator, JpaErrorLoggingAspect +└── gateway/ # Gateway DB 직접 접근 (portal 의 하위가 아님) ``` +> `apps/proxy/` 패키지는 **더 이상 없다**. 과거 문서의 Forward Proxy 모듈 설명은 무효다. + 각 기능 모듈은 일반적으로 다음을 포함합니다: - `controller/` - Spring MVC 컨트롤러 (@Controller) - `service/` - 비즈니스 로직 (@Service, @Transactional) @@ -183,6 +224,9 @@ com.eactive.apim.portal/ - Entities: `com.eactive.eai.data.entity.onl.*` - 목적: API 명세, 서비스, 메시지 +두 데이터소스 모두 **dev 프로파일에서도 JNDI 로 연결한다**(`application-dev.yml`, `application-stage.yml`). +직접 JDBC URL 을 쓰는 프로파일은 없다. + 설정: `config/PortalDatasourceConfiguration.java` - 각 데이터베이스별 별도 EntityManager - **JTA/XA 미사용**. EntityManagerFactory 별 로컬 트랜잭션 (`config/PortalConfigTransaction.java`) @@ -193,18 +237,25 @@ com.eactive.apim.portal/ 환경별 설정 파일: `src/main/resources/application-{profile}.yml` -- **dev**: 개발 환경 (Oracle at 192.168.240.177:1599, DevTools 활성화, SQL 로깅) +저장소에 실제로 있는 프로파일은 4개다: + +- **dev**: 개발 환경 (DevTools 활성화, SQL 로깅) - **stage**: 스테이징 환경 (JNDI 데이터소스, proxy to inter-dapiwas01) - **prod**: 운영 환경 (JNDI 데이터소스, proxy to inter-apiwas00, 캐싱 활성화) -- **gf63**: 개인 개발 환경 -- **kjb_rinjae**: 개인 개발 환경 +- **local_gf63**: 개인 로컬 개발 환경 -주요 설정: -- 세션 타임아웃: 15분 -- 파일 업로드 최대: 8MB -- 비밀번호 만료: 90일 -- 인증 토큰 TTL: 5분 (300초) -- 사용자 승인 필수: true +> `bootRun` 의 기본값은 `build.gradle` 에서 `local_rinjaemac` 으로 잡혀 있지만 +> `application-local_rinjaemac.yml` 은 저장소에 없다. 기본값 그대로 `gradle bootRun` 을 +> 하면 해당 프로파일 설정 없이 뜨므로, 로컬 기동 시에는 `-PbootProfile=dev` 처럼 명시하는 편이 안전하다. + +주요 설정 (`application.yml`): +- 세션 타임아웃: **10분** (`server.servlet.session.timeout: 10m`, WebLogic 은 `weblogic.xml` 의 `timeout-secs 600`) +- 세션 쿠키명: `JSESSIONID_PORTAL` +- 파일 업로드 최대: **10MB** (`portal.file.max-size`) +- 비밀번호 만료: 90일 (`portal.password-expiration-days`) +- 인증 토큰 TTL: 5분 (`portal.auth-ttl: 300`), 재발송 제한 30초 +- 사용자 승인 필수: `portal.user-approval: true` +- 내부 사용자 판별 도메인: `portal.internal-user.email-domains` ## 주요 기능 및 비즈니스 로직 @@ -237,12 +288,14 @@ API 키는 승인 워크플로우와 함께 관리됩니다: 엔티티: `Approval`, `Approver`, `ApprovalStatus` (WAITING, APPROVED, REJECTED) -### API 테스트 (`apps/proxy/`, `apps/sample/`) +### API 테스트 (`apps/apis/`) -- Gateway 서버로의 Forward Proxy -- 요청/응답 로깅이 있는 API 테스터 -- 여러 언어의 샘플 코드 생성 -- API 문서를 위한 Swagger UI 통합 +- API 테스터: `apps/apis/filter/ApiTesterFilter.java` +- Testbed 스펙 제공: `apps/apis/controller/TestbedSpecController.java` +- 샘플 코드 템플릿: `application.yml` 의 `sample-code-path: classpath:/templates/sample_code` + +> `apps/sample/` 은 샘플 코드 생성기가 아니라 **Thymeleaf/Security 데모 컨트롤러** +> (`ThymeleafDemoController`, `SecurityThymeleafDemoController` 등) 모음이다. ### 감사 추적 (Hibernate Envers) @@ -321,16 +374,22 @@ public interface UserMapper { 보안 설정: `config/PortalConfigSecurity.java` - 커스텀 인증 관리자: `PortalAuthenticationManager` - `changeSessionId()`를 통한 세션 고정 공격 방어 -- 쿠키 기반 토큰을 사용한 CSRF 방어 -- Lucy 필터를 통한 XSS 방어 +- CSRF: **`HttpSessionCsrfTokenRepository`(세션 저장)**, 헤더명 `X-XSRF-TOKEN` 고정 + — 쿠키 기반(`CookieCsrfTokenRepository`)이 아니다. 토큰 수명은 세션 타임아웃과 같다 +- XSS: Naver Lucy `XssEscapeServletFilter` +- 비밀번호 전송암호화(RSA-OAEP + AES-GCM): `common/security/passwordcrypto/` + — 필터 순서상 MultipartFilter 이후, Lucy XSS 필터 이전에 복호화된다 -역할 계층: +역할 계층 (정의: `src/main/resources/roles.yml`, 부팅 시 `PTL_ROLE` / `PTL_ROLE_AUTHORITY` 에 미러 적재): ``` -ROLE_USER → [ROLE_INQUIRY, ROLE_ACCOUNT] -ROLE_CORP_USER → [ROLE_INQUIRY, ROLE_APP, ROLE_ACCOUNT] -ROLE_CORP_MANAGER → [ROLE_API_KEY_REQUEST, ROLE_INQUIRY, ROLE_APP, - ROLE_ACCOUNT, ROLE_CORP_API, ROLE_DASHBOARD, - ROLE_USER_MANAGER] +ROLE_USER (개인사용자) → [ROLE_INQUIRY, ROLE_ACCOUNT] + +ROLE_CORP_USER (법인사용자) → [ROLE_API_KEY_REQUEST, ROLE_API_KEY_REQUEST_VIEW, + ROLE_INQUIRY, ROLE_APP, ROLE_ACCOUNT] + +ROLE_CORP_MANAGER (법인관리자) → [ROLE_API_KEY_REQUEST, ROLE_API_KEY_REQUEST_VIEW, + ROLE_WEBHOOK, ROLE_INQUIRY, ROLE_APP, ROLE_ACCOUNT, + ROLE_CORP_API, ROLE_DASHBOARD, ROLE_USER_MANAGER] ``` ### 데이터베이스 쿼리 @@ -377,15 +436,16 @@ return queryFactory.selectFrom(user) ### 로깅 -`application.yml`의 로깅 설정: -```yaml -logging: - file: - path: /Log/App/eapim/ - level: - com.eactive.apim.portal: DEBUG - org.hibernate.SQL: DEBUG (개발 환경만) -``` +로깅은 `application.yml` 의 `logging.file.path` 가 아니라 **logback 설정으로 제어**한다. + +- 설정 파일: `src/main/resources/logback-spring.xml` (그 외 `logback-debug.xml`, `logback-local_gf63.xml`) +- 로그 디렉터리: `portal.logging.log-path` yml 프로퍼티 + 인스턴스명 + ```xml + + + ``` +- `application.yml` 기본값: `portal.logging.log-path: /logs/prod/eapim` +- 산출 파일: `${LOG_PATH}/portal.log`, `${LOG_PATH}/hibernate.log` 등 (`backup/` 에 일자 롤링) Lombok의 `@Slf4j`와 함께 SLF4J 사용: ```java @@ -434,12 +494,10 @@ class UserControllerTest extends BaseWebTest { } ``` -### SafeDB 테스트 +### SafeDB -테스트에서는 기본적으로 SafeDB 암호화가 비활성화됩니다. 필요한 경우: -```bash -gradle test -Dsafedb=/path/to/safedb -``` +SafeDB 모듈이 현재 빌드에서 빠져 있으므로(위 "멀티 모듈 구조" 참고) 테스트에서 +별도로 켜고 끌 것이 없다. `-Dsafedb=` 옵션은 더 이상 동작하지 않는다. ## 중요 사항 @@ -451,15 +509,6 @@ gradle test -Dsafedb=/path/to/safedb - 한글 메시지 프로퍼티 (`messages_ko.properties`) - 한글 사용자 가이드 (`개발자포탈.md`) -### Forward Proxy 설정 - -API 테스트는 Gateway로의 Forward Proxy 사용: -- Dev: `http://localhost:10000` -- Stage: `http://inter-dapiwas01:10000` -- Prod: `http://inter-apiwas00:10000` - -설정: `apps/proxy/service/ProxyService.java` - ### 비밀번호 정책 `PortalUserValidator`에서 적용: @@ -472,29 +521,63 @@ API 테스트는 Gateway로의 Forward Proxy 사용: ### 세션 관리 - 사용자당 단일 세션 강제 -- 세션 타임아웃: 15분 -- 로그인 시 세션 고정 공격 방어 -- Redis/Ehcache를 통한 세션 클러스터링 (스테이징/운영) +- 세션 타임아웃: **10분** (쿠키명 `JSESSIONID_PORTAL`) +- 로그인 시 `changeSessionId()` 로 세션 고정 공격 방어 +- 세션 상태는 DB 에 보관하며 `apps/session/` (filter/entity/repository) 과 + `spring/DatabaseSessionVerifier.java` 가 담당한다. **Redis 는 쓰지 않는다.** ### 파일 업로드 제한 -- 최대 파일 크기: 8MB -- `FileService`에서 허용 확장자 설정 -- 파일 저장 위치: `/Log/App/eapim/files/` -- 업로드 시 XSS 스캐닝 +`application.yml` 의 `portal.file` 로 제어하고 `config/PortalProperties.java` 가 바인딩한다: + +```yaml +portal: + file: + max-size: 10MB + allowed-extensions: pdf,doc,docx,xls,xlsx,ppt,pptx,hwp,gif,jpg,jpeg,png +``` + +- 적용 지점: `config/MultipartConfig.java` (`setMaxUploadSize` / `setMaxUploadSizePerFile`) +- 초과 시 메시지: `common/exception/PortalGlobalExceptionHandler.java` +- `PortalProperties` 의 코드 기본값은 `8MB` 이지만 yml 이 `10MB` 로 덮어쓴다 +- 다운로드는 `apps/file/controller/FileDownloadController.java` 하나뿐이다 ## 배포 ### 애플리케이션 서버 지원 WAR 파일 호환 서버: -- **JEUS** (`jeus-web-dd.xml` 필요) -- **WebLogic** (`weblogic.xml` 필요) +- **WebLogic** (`src/main/resources/weblogic.xml`) — 현재 실제 배포 대상 +- **JEUS** (`src/main/resources/jeus-web-dd.xml`) - **Tomcat** (Spring Boot 내장) +실제 배포는 `Jenkinsfile.deploy` 가 수행한다: `djb-vm` 노드에서 WAR 를 빌드해 stash 하고, +`weblogic` 라벨 노드에서 WebLogic 정지 → WAR 교체 → 기동 → readiness 확인 순으로 진행한다. + +### Jenkins 파이프라인 + +저장소 루트에 4개의 Jenkinsfile 이 있고 모두 `origin/master` 를 대상으로 한다: + +| 파일 | 용도 | +|---|---| +| `Jenkinsfile.security` | OWASP Dependency-Check(SCA) + SonarQube(SAST). pollSCM 자동 트리거 | +| `Jenkinsfile.sonar` | SonarQube 정적 분석 전용 | +| `Jenkinsfile.test-build` | 테스트 + WAR 빌드 + SBOM | +| `Jenkinsfile.deploy` | 빌드 후 WebLogic 배포 | + +빌드는 JDK 8(`/apps/opts/jdk8`), SonarScanner/Dependency-Check 실행은 JDK 17 +(`/apps/opts/jdk17`)로 분리되어 있다. 스캐너 설정은 `sonar-project.properties` 와 +`ci/` 디렉터리(`sonar-classpath.gradle`, `dependency-check-classpath.gradle`, +`dependency-check-suppressions.xml`)에 있다. + +알려진 CI 이슈 두 가지: +- **Coverage 0% → Quality Gate ERROR**: JaCoCo 미설정 (위 "테스트" 섹션 참고) +- **Dependency-Check NVD 갱신 실패**: Jenkins 에 `nvd-api-key` credential 이 없다. + 폐쇄망/키 부재 시에는 `UPDATE_NVD=false` 로 실행해 캐시 DB 로만 검사한다. + ### JNDI 설정 필수 -스테이징/운영 환경에서 JNDI 리소스 설정: +dev 를 포함한 모든 환경에서 WAS 에 JNDI 리소스가 있어야 한다: - `jdbc/dsOBP_EMS` → Portal 데이터베이스 - `jdbc/dsOBP_AGW` → Gateway 데이터베이스 @@ -511,8 +594,8 @@ JAVA_OPTS="-Xmx2g -Xms1g -Dspring.profiles.active=prod" # WAR 빌드 gradle bootWar -# Docker 이미지 빌드 -./build_docker.sh +# Docker 이미지 빌드 (build_docker.sh 는 없다) +docker build -t eapim-portal:latest . # 컨테이너 실행 docker run -p 30200:30200 \ @@ -526,13 +609,16 @@ docker run -p 30200:30200 \ 다음 항목에 영향을 주는 변경 시: - **Gateway API 명세**: `elink-online-core-jpa` 모듈 확인 - **공통 유틸리티**: `elink-portal-common` 모듈 확인 -- **암호화**: `kjb-safedb` 모듈 확인 +- **암호화**: `kjb-safedb` 모듈 — 현재 빌드에서 제외됨 (위 "멀티 모듈 구조" 참고) - **Admin 포털**: `../eapim-admin/` 관련 프로젝트 - **Online 포털**: `../eapim-online/` 관련 프로젝트 ## 추가 자료 - 사용자 가이드 (한글): `개발자포탈.md` -- 빌드 스크립트: `build-gf63.sh`, `deploy_portal.sh`, `deploy_portal2.sh` -- Docker: `Dockerfile`, `build_docker.sh` -- SQL 스크립트: `../kjb-eapim-sql/` +- 로컬 기동 가이드 (Windows/Eclipse): `BOOTRUN_SETUP_GUIDE.md` +- 개발환경 준비, OHS 정적리소스 설정: `djb-docs/` +- 메뉴 관리 개발 가이드: `readme-docs/메뉴-관리-개발-가이드.md` +- SASS/프론트 빌드: `package.json`, `sass-build.sh`, `tools/forge-entry.js` +- Docker: `Dockerfile` +- CI 설정: `Jenkinsfile.*`, `sonar-project.properties`, `ci/` diff --git a/Jenkinsfile.security b/Jenkinsfile.security index 63f8c06..b58430f 100644 --- a/Jenkinsfile.security +++ b/Jenkinsfile.security @@ -43,8 +43,9 @@ pipeline { ) booleanParam( name: 'RUN_TESTS', - defaultValue: false, - description: '단위 테스트를 함께 실행해 JUnit 결과를 Sonar 로 전송한다(분석 시간 증가).' + defaultValue: true, + description: '단위 테스트 + JaCoCo 커버리지를 Sonar 로 전송한다(분석 시간 증가). ' + + '끄면 커버리지 리포트가 없어 Sonar 가 Coverage 를 0% 로 집계하고 Quality Gate 가 실패한다.' ) booleanParam( name: 'UPDATE_NVD', @@ -166,14 +167,18 @@ pipeline { } } + // test 는 finalizedBy jacocoTestReport 로 커버리지 XML 까지 만든다(build.gradle). + // 이 스테이지를 건너뛰면 Sonar 가 커버리지 리포트를 못 받아 Coverage 0% 로 집계되고 + // Quality Gate 의 "새 코드 커버리지" 조건에서 반드시 실패한다. stage('Test') { when { expression { return params.RUN_TESTS } } steps { - sh 'gradle test --no-daemon' + sh 'gradle test jacocoTestReport --no-daemon' } post { always { junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml' + archiveArtifacts artifacts: 'build/reports/jacoco/test/**', allowEmptyArchive: true, fingerprint: false } } } @@ -419,6 +424,16 @@ pipeline { JUNIT_ARG="-Dsonar.junit.reportPaths=build/test-results/test" fi + # 커버리지. sonar.junit.reportPaths 는 테스트 성공/실패 개수일 뿐 + # 커버리지가 아니다. JaCoCo XML 을 따로 넘겨야 Coverage 지표가 채워진다. + COVERAGE_ARG="" + JACOCO_XML=build/reports/jacoco/test/jacocoTestReport.xml + if [ -f "$JACOCO_XML" ]; then + COVERAGE_ARG="-Dsonar.coverage.jacoco.xmlReportPaths=$JACOCO_XML" + else + echo "WARN: $JACOCO_XML 없음 - Coverage 는 0% 로 집계된다(RUN_TESTS 를 켰는지 확인)." + fi + # SonarQube 에 Dependency-Check 플러그인이 설치돼 있으면 CVE 결과도 함께 올린다. # 플러그인이 없으면 스캐너가 모르는 속성으로 무시한다(경고만). DC_ARG="" @@ -445,6 +460,7 @@ pipeline { -Dsonar.java.libraries="$LIBS" \ -Dsonar.java.test.libraries="$TEST_LIBS" \ $JUNIT_ARG \ + $COVERAGE_ARG \ $DC_ARG ''' } diff --git a/Jenkinsfile.sonar b/Jenkinsfile.sonar index 2c7a2a5..b5de60c 100644 --- a/Jenkinsfile.sonar +++ b/Jenkinsfile.sonar @@ -26,8 +26,9 @@ pipeline { parameters { booleanParam( name: 'RUN_TESTS', - defaultValue: false, - description: '단위 테스트를 함께 실행해 JUnit 결과를 Sonar 로 전송한다(분석 시간 증가).' + defaultValue: true, + description: '단위 테스트 + JaCoCo 커버리지를 Sonar 로 전송한다(분석 시간 증가). ' + + '끄면 커버리지 리포트가 없어 Sonar 가 Coverage 를 0% 로 집계하고 Quality Gate 가 실패한다.' ) } @@ -121,14 +122,16 @@ pipeline { } } + // test 는 finalizedBy jacocoTestReport 로 커버리지 XML 까지 만든다(build.gradle). stage('Test') { when { expression { return params.RUN_TESTS } } steps { - sh 'gradle test --no-daemon' + sh 'gradle test jacocoTestReport --no-daemon' } post { always { junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml' + archiveArtifacts artifacts: 'build/reports/jacoco/test/**', allowEmptyArchive: true, fingerprint: false } } } @@ -199,6 +202,15 @@ pipeline { JUNIT_ARG="-Dsonar.junit.reportPaths=build/test-results/test" fi + # 커버리지. JUNIT_ARG 는 테스트 성공/실패 개수일 뿐 커버리지가 아니다. + COVERAGE_ARG="" + JACOCO_XML=build/reports/jacoco/test/jacocoTestReport.xml + if [ -f "$JACOCO_XML" ]; then + COVERAGE_ARG="-Dsonar.coverage.jacoco.xmlReportPaths=$JACOCO_XML" + else + echo "WARN: $JACOCO_XML 없음 - Coverage 는 0% 로 집계된다(RUN_TESTS 를 켰는지 확인)." + fi + # Tools 에 등록된 스캐너가 없으면 PATH 에서 찾는다 if [ -n "${SONAR_SCANNER_HOME:-}" ] && [ -x "$SONAR_SCANNER_HOME/bin/sonar-scanner" ]; then SCANNER="$SONAR_SCANNER_HOME/bin/sonar-scanner" @@ -215,7 +227,8 @@ pipeline { -Dsonar.projectVersion="${BUILD_NUMBER}" \ -Dsonar.java.libraries="$LIBS" \ -Dsonar.java.test.libraries="$TEST_LIBS" \ - $JUNIT_ARG + $JUNIT_ARG \ + $COVERAGE_ARG ''' } } diff --git a/build.gradle b/build.gradle index 2d286a5..02724a8 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,7 @@ plugins { id 'war' id 'eclipse' id 'idea' + id 'jacoco' id 'org.cyclonedx.bom' version '3.2.4' id 'org.springframework.boot' version '2.7.18' id 'io.spring.dependency-management' version '1.1.3' @@ -290,6 +291,33 @@ test { } } enabled = true + + // 테스트가 끝나면 커버리지 리포트를 자동 생성한다. + // SonarQube 는 리포트가 없으면 "미측정"이 아니라 0% 로 집계하므로(Zero Coverage Sensor) + // 리포트 생성이 빠지면 Quality Gate 의 "새 코드 커버리지" 조건에서 항상 실패한다. + finalizedBy jacocoTestReport +} + +// JaCoCo 0.8.11 = Gradle 8.7 기본값. JDK 8 바이트코드 계측 지원. +jacoco { + toolVersion = '0.8.11' +} + +jacocoTestReport { + dependsOn test + + reports { + // Sonar 가 읽는 형식은 XML 뿐이다. HTML 은 사람이 볼 용도. + xml.required = true + html.required = true + csv.required = false + } + + // 여기서 클래스를 제외하지 말 것. + // JaCoCo 리포트에서 빠진 클래스는 Sonar 에 "데이터 없음"으로 도달하고, + // Sonar 의 Zero Coverage Sensor 가 그 파일을 0% 로 채워 넣는다(= 오히려 악화). + // 커버리지 집계에서 빼고 싶은 파일은 sonar-project.properties 의 + // sonar.coverage.exclusions 로 지정한다. } // 로컬 전용 설정 파일. 배포 산출물(WAR)에 실리면 Actuator/SBA 설정이 그대로 노출되어 diff --git a/sonar-project.properties b/sonar-project.properties index 105884f..f7d419a 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -27,6 +27,23 @@ sonar.java.source=8 sonar.java.binaries=build/classes/java/main sonar.java.test.binaries=build/classes/java/test +# --- 커버리지 -------------------------------------------------------------- +# JaCoCo XML 경로. build.gradle 의 test 가 finalizedBy jacocoTestReport 로 생성한다. +# 이 값이 없으면 Sonar 는 커버리지를 "미측정"이 아니라 0% 로 집계한다(Zero Coverage Sensor). +# Jenkins 는 파일 존재 여부를 확인한 뒤 -D 로 한 번 더 덮어쓴다. +sonar.coverage.jacoco.xmlReportPaths=build/reports/jacoco/test/jacocoTestReport.xml + +# 커버리지 집계에서 빼는 대상. +# 주의: JaCoCo 쪽에서 클래스를 빼면 Sonar 가 0% 로 채우므로, 제외는 반드시 여기서 한다. +# 실행 로직이 없어 테스트 대상이 아닌 것들만 최소로 지정한다. +sonar.coverage.exclusions=\ + **/PortalApplication.java,\ + **/config/**,\ + **/dto/**,\ + **/*Dto.java,\ + **/entity/**,\ + **/mapper/** + # --- 제외 대상 ------------------------------------------------------------- # 서드파티 번들/컴파일 산출물/바이너리. 분석해도 우리가 고칠 수 없는 코드는 뺀다. sonar.exclusions=\ diff --git a/src/test/java/com/eactive/apim/portal/apps/agreements/controller/AgreementsControllerTest.java b/src/test/java/com/eactive/apim/portal/apps/agreements/controller/AgreementsControllerTest.java new file mode 100644 index 0000000..c334bd8 --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/apps/agreements/controller/AgreementsControllerTest.java @@ -0,0 +1,240 @@ +package com.eactive.apim.portal.apps.agreements.controller; + +import com.eactive.apim.portal.agreements.entity.AgreementType; +import com.eactive.apim.portal.agreements.service.AgreementTypeConfigService; +import com.eactive.apim.portal.apps.agreements.dto.AgreementTabDTO; +import com.eactive.apim.portal.apps.agreements.dto.AgreementsDTO; +import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +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.ui.ExtendedModelMap; +import org.springframework.ui.Model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 약관 페이지({@code /agreements/terms}) 탭 해석 검증. + * + *

노출 대상과 순서는 관리 콘솔의 '약관 종류 관리' 설정을 따르고, + * 기존 북마크 호환을 위해 {@code tab} 슬러그는 고정이다. + */ +@ExtendWith(MockitoExtension.class) +class AgreementsControllerTest { + + @Mock + private AgreementsFacade agreementsFacade; + + @Mock + private AgreementTypeConfigService agreementTypeConfigService; + + @InjectMocks + private AgreementsController agreementsController; + + private Model model; + + @BeforeEach + void setUp() { + model = new ExtendedModelMap(); + } + + private AgreementsDTO agreement(String name, LocalDateTime publishedOn) { + AgreementsDTO dto = new AgreementsDTO(); + dto.setName(name); + dto.setPublishedOn(publishedOn); + return dto; + } + + @SuppressWarnings("unchecked") + private List tabs() { + return (List) model.getAttribute("termsTabs"); + } + + @Test + @DisplayName("tab=privacy 는 외부 개인정보처리방침으로 리다이렉트한다(북마크 호환)") + void showTerms_privacyRedirectsToExternalUrl() { + String view = agreementsController.showTerms("privacy", null, model); + + assertEquals("redirect:" + AgreementsController.PRIVACY_POLICY_EXTERNAL_URL, view); + // 리다이렉트 경로에서는 설정 조회조차 하지 않는다 + verify(agreementTypeConfigService, never()).getDisplayTypes(); + verify(agreementsFacade, never()).getAgreementsList(org.mockito.ArgumentMatchers.anyString()); + } + + @Test + @DisplayName("노출할 약관 종류가 없으면 빈 화면으로 방어한다") + void showTerms_noDisplayTypes() { + when(agreementTypeConfigService.getDisplayTypes()).thenReturn(Collections.emptyList()); + + String view = agreementsController.showTerms(null, "2026-01-01", model); + + assertEquals(AgreementsController.TERMS_AGREEMENTS, view); + assertTrue(tabs().isEmpty()); + assertTrue(((List) model.getAttribute("agreementsList")).isEmpty()); + assertNull(model.getAttribute("selectedAgreement")); + assertEquals("2026-01-01", model.getAttribute("selectedDate")); + assertEquals("약관", model.getAttribute("agreementTitle")); + assertNull(model.getAttribute("agreementType")); + assertNull(model.getAttribute("currentTab")); + + verify(agreementsFacade, never()).getAgreementsList(org.mockito.ArgumentMatchers.anyString()); + } + + @Test + @DisplayName("tab 이 없으면 노출 목록의 첫 번째 약관을 연다") + void showTerms_defaultsToFirstDisplayType() { + AgreementsDTO latest = agreement("이용약관 v2", LocalDateTime.of(2026, 3, 1, 0, 0)); + AgreementsDTO older = agreement("이용약관 v1", LocalDateTime.of(2025, 1, 1, 0, 0)); + + when(agreementTypeConfigService.getDisplayTypes()).thenReturn(Arrays.asList( + AgreementType.TERMS_OF_USE, AgreementType.NOTIFICATION_CONSENT)); + when(agreementsFacade.getAgreementsList(AgreementType.TERMS_OF_USE.name())) + .thenReturn(Arrays.asList(latest, older)); + + String view = agreementsController.showTerms(null, null, model); + + assertEquals(AgreementsController.TERMS_AGREEMENTS, view); + assertEquals("terms", model.getAttribute("currentTab")); + assertEquals(AgreementType.TERMS_OF_USE.getCode(), model.getAttribute("agreementType")); + assertEquals(AgreementType.TERMS_OF_USE.getDescription(), model.getAttribute("agreementTitle")); + // publishedOn 미지정이면 목록의 첫 항목 + assertSame(latest, model.getAttribute("selectedAgreement")); + + List tabs = tabs(); + assertEquals(Arrays.asList("terms", "notification"), + tabs.stream().map(AgreementTabDTO::getTab).collect(Collectors.toList())); + assertTrue(tabs.get(0).isActive()); + assertEquals(false, tabs.get(1).isActive()); + } + + @Test + @DisplayName("tab 슬러그가 노출 목록에 있으면 그 약관을 연다") + void showTerms_selectsRequestedTab() { + AgreementsDTO notification = agreement("알림 수신 동의서", LocalDateTime.of(2026, 2, 1, 0, 0)); + + when(agreementTypeConfigService.getDisplayTypes()).thenReturn(Arrays.asList( + AgreementType.TERMS_OF_USE, AgreementType.NOTIFICATION_CONSENT)); + when(agreementsFacade.getAgreementsList(AgreementType.NOTIFICATION_CONSENT.name())) + .thenReturn(Collections.singletonList(notification)); + + String view = agreementsController.showTerms("notification", null, model); + + assertEquals(AgreementsController.TERMS_AGREEMENTS, view); + assertEquals("notification", model.getAttribute("currentTab")); + assertEquals(AgreementType.NOTIFICATION_CONSENT.getCode(), model.getAttribute("agreementType")); + + List tabs = tabs(); + assertEquals(false, tabs.get(0).isActive()); + assertTrue(tabs.get(1).isActive()); + } + + @Test + @DisplayName("노출 목록에 없는 tab 은 첫 번째 탭으로 보정한다") + void showTerms_unknownTabFallsBackToFirst() { + AgreementsDTO privacyCollect = agreement("개인정보수집동의서", LocalDateTime.of(2026, 1, 5, 0, 0)); + + // notification 은 노출 대상이 아니다 + when(agreementTypeConfigService.getDisplayTypes()) + .thenReturn(Collections.singletonList(AgreementType.PRIVACY_COLLECT)); + when(agreementsFacade.getAgreementsList(AgreementType.PRIVACY_COLLECT.name())) + .thenReturn(Collections.singletonList(privacyCollect)); + + String view = agreementsController.showTerms("notification", null, model); + + assertEquals(AgreementsController.TERMS_AGREEMENTS, view); + assertEquals("privacy-collect", model.getAttribute("currentTab")); + assertEquals(AgreementType.PRIVACY_COLLECT.getCode(), model.getAttribute("agreementType")); + } + + @Test + @DisplayName("빈 tab 문자열도 첫 번째 탭으로 보정한다") + void showTerms_emptyTabFallsBackToFirst() { + AgreementsDTO terms = agreement("이용약관", LocalDateTime.of(2026, 1, 1, 0, 0)); + + when(agreementTypeConfigService.getDisplayTypes()) + .thenReturn(Collections.singletonList(AgreementType.TERMS_OF_USE)); + when(agreementsFacade.getAgreementsList(AgreementType.TERMS_OF_USE.name())) + .thenReturn(Collections.singletonList(terms)); + + String view = agreementsController.showTerms("", null, model); + + assertEquals(AgreementsController.TERMS_AGREEMENTS, view); + assertEquals("terms", model.getAttribute("currentTab")); + } + + @Test + @DisplayName("publishedOn 이 주어지면 해당 제정일의 개정본을 고른다") + void showTerms_picksAgreementByPublishedOn() { + AgreementsDTO latest = agreement("v2", LocalDateTime.of(2026, 3, 1, 9, 30)); + AgreementsDTO older = agreement("v1", LocalDateTime.of(2025, 1, 15, 0, 0)); + + when(agreementTypeConfigService.getDisplayTypes()) + .thenReturn(Collections.singletonList(AgreementType.TERMS_OF_USE)); + when(agreementsFacade.getAgreementsList(AgreementType.TERMS_OF_USE.name())) + .thenReturn(Arrays.asList(latest, older)); + + agreementsController.showTerms("terms", "2025-01-15", model); + + assertSame(older, model.getAttribute("selectedAgreement")); + assertEquals("2025-01-15", model.getAttribute("selectedDate")); + } + + @Test + @DisplayName("일치하는 제정일이 없으면 첫 항목으로 되돌린다") + void showTerms_unmatchedPublishedOnFallsBackToFirst() { + AgreementsDTO latest = agreement("v2", LocalDateTime.of(2026, 3, 1, 0, 0)); + + when(agreementTypeConfigService.getDisplayTypes()) + .thenReturn(Collections.singletonList(AgreementType.TERMS_OF_USE)); + when(agreementsFacade.getAgreementsList(AgreementType.TERMS_OF_USE.name())) + .thenReturn(Collections.singletonList(latest)); + + agreementsController.showTerms("terms", "1999-12-31", model); + + assertSame(latest, model.getAttribute("selectedAgreement")); + } + + @Test + @DisplayName("아직 시드되지 않은 약관이면 selectedAgreement 는 null 이다") + void showTerms_emptyAgreementListIsDefended() { + when(agreementTypeConfigService.getDisplayTypes()) + .thenReturn(Collections.singletonList(AgreementType.TERMS_OF_USE)); + when(agreementsFacade.getAgreementsList(AgreementType.TERMS_OF_USE.name())) + .thenReturn(Collections.emptyList()); + + String view = agreementsController.showTerms("terms", "2026-01-01", model); + + assertEquals(AgreementsController.TERMS_AGREEMENTS, view); + assertNull(model.getAttribute("selectedAgreement")); + assertEquals("terms", model.getAttribute("currentTab")); + } + + @Test + @DisplayName("목록 조회가 null 을 돌려줘도 방어한다") + void showTerms_nullAgreementListIsDefended() { + when(agreementTypeConfigService.getDisplayTypes()) + .thenReturn(Collections.singletonList(AgreementType.TERMS_OF_USE)); + when(agreementsFacade.getAgreementsList(AgreementType.TERMS_OF_USE.name())) + .thenReturn(null); + + String view = agreementsController.showTerms("terms", null, model); + + assertEquals(AgreementsController.TERMS_AGREEMENTS, view); + assertNull(model.getAttribute("selectedAgreement")); + } +} diff --git a/src/test/java/com/eactive/apim/portal/apps/agreements/service/AgreementFormModelSupportTest.java b/src/test/java/com/eactive/apim/portal/apps/agreements/service/AgreementFormModelSupportTest.java new file mode 100644 index 0000000..289c816 --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/apps/agreements/service/AgreementFormModelSupportTest.java @@ -0,0 +1,159 @@ +package com.eactive.apim.portal.apps.agreements.service; + +import com.eactive.apim.portal.agreements.entity.AgreementType; +import com.eactive.apim.portal.agreements.service.AgreementTypeConfigService; +import com.eactive.apim.portal.apps.agreements.dto.AgreementsDTO; +import java.util.Collections; +import java.util.EnumSet; +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.ui.ExtendedModelMap; +import org.springframework.ui.Model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 약관 동의 폼 모델 구성 검증. + * + *

핵심 규칙: 관리 콘솔에서 사용하지 않도록 설정한 약관 종류는 + * {@code show*} 플래그가 {@code false} 이고 본문도 {@code null} 이어야 한다. + */ +@ExtendWith(MockitoExtension.class) +class AgreementFormModelSupportTest { + + @Mock + private AgreementsFacade agreementsFacade; + + @Mock + private AgreementTypeConfigService agreementTypeConfigService; + + @InjectMocks + private AgreementFormModelSupport agreementFormModelSupport; + + private Model model; + + @BeforeEach + void setUp() { + model = new ExtendedModelMap(); + } + + private AgreementsDTO agreement(String name) { + AgreementsDTO dto = new AgreementsDTO(); + dto.setName(name); + return dto; + } + + @Test + @DisplayName("세 종류 모두 사용 중이면 플래그가 켜지고 본문이 채워진다") + void applyAgreements_allEnabled() { + AgreementsDTO terms = agreement("이용약관"); + AgreementsDTO privacy = agreement("개인정보수집동의서"); + AgreementsDTO notification = agreement("알림 수신 동의서"); + + when(agreementTypeConfigService.isAgreeAllEnabled()).thenReturn(true); + when(agreementTypeConfigService.getEnabledTypes()).thenReturn(EnumSet.of( + AgreementType.TERMS_OF_USE, + AgreementType.PRIVACY_COLLECT, + AgreementType.NOTIFICATION_CONSENT)); + when(agreementsFacade.getAgreement(AgreementType.TERMS_OF_USE.getCode())).thenReturn(terms); + when(agreementsFacade.getAgreement(AgreementType.PRIVACY_COLLECT.getCode())).thenReturn(privacy); + when(agreementsFacade.getAgreement(AgreementType.NOTIFICATION_CONSENT.getCode())).thenReturn(notification); + + agreementFormModelSupport.applyAgreements(model); + + assertEquals(Boolean.TRUE, model.getAttribute("agreeAllEnabled")); + + assertEquals(Boolean.TRUE, model.getAttribute("showTermsOfUse")); + assertEquals(Boolean.TRUE, model.getAttribute("showPrivacyCollect")); + assertEquals(Boolean.TRUE, model.getAttribute("showNotificationConsent")); + + assertSame(terms, model.getAttribute("termsOfUse")); + assertSame(privacy, model.getAttribute("privacyCollect")); + assertSame(notification, model.getAttribute("notificationConsent")); + } + + @Test + @DisplayName("사용하지 않는 종류는 플래그가 꺼지고 본문을 조회조차 하지 않는다") + void applyAgreements_disabledTypeIsHiddenAndNotFetched() { + AgreementsDTO terms = agreement("이용약관"); + + when(agreementTypeConfigService.isAgreeAllEnabled()).thenReturn(false); + when(agreementTypeConfigService.getEnabledTypes()) + .thenReturn(EnumSet.of(AgreementType.TERMS_OF_USE)); + when(agreementsFacade.getAgreement(AgreementType.TERMS_OF_USE.getCode())).thenReturn(terms); + + agreementFormModelSupport.applyAgreements(model); + + assertEquals(Boolean.FALSE, model.getAttribute("agreeAllEnabled")); + + assertEquals(Boolean.TRUE, model.getAttribute("showTermsOfUse")); + assertSame(terms, model.getAttribute("termsOfUse")); + + // 꺼진 항목은 플래그 false + 본문 null 이어야 화면에서 항목 자체가 사라진다 + assertEquals(Boolean.FALSE, model.getAttribute("showPrivacyCollect")); + assertEquals(Boolean.FALSE, model.getAttribute("showNotificationConsent")); + assertTrue(model.containsAttribute("privacyCollect")); + assertTrue(model.containsAttribute("notificationConsent")); + assertNull(model.getAttribute("privacyCollect")); + assertNull(model.getAttribute("notificationConsent")); + + // 불필요한 조회가 나가면 안 된다 + verify(agreementsFacade, never()).getAgreement(AgreementType.PRIVACY_COLLECT.getCode()); + verify(agreementsFacade, never()).getAgreement(AgreementType.NOTIFICATION_CONSENT.getCode()); + } + + @Test + @DisplayName("사용 중인 종류가 하나도 없으면 전부 숨김 처리된다") + void applyAgreements_noneEnabled() { + when(agreementTypeConfigService.isAgreeAllEnabled()).thenReturn(false); + when(agreementTypeConfigService.getEnabledTypes()).thenReturn(Collections.emptySet()); + + agreementFormModelSupport.applyAgreements(model); + + assertEquals(Boolean.FALSE, model.getAttribute("showTermsOfUse")); + assertEquals(Boolean.FALSE, model.getAttribute("showPrivacyCollect")); + assertEquals(Boolean.FALSE, model.getAttribute("showNotificationConsent")); + assertNull(model.getAttribute("termsOfUse")); + assertNull(model.getAttribute("privacyCollect")); + assertNull(model.getAttribute("notificationConsent")); + + verify(agreementsFacade, never()).getAgreement(org.mockito.ArgumentMatchers.anyString()); + } + + @Test + @DisplayName("applyAgreeAllMode 는 전체동의 허용 여부만 담고 동의 항목은 건드리지 않는다") + void applyAgreeAllMode_onlyTouchesAgreeAllFlag() { + when(agreementTypeConfigService.isAgreeAllEnabled()).thenReturn(true); + + agreementFormModelSupport.applyAgreeAllMode(model); + + assertEquals(Boolean.TRUE, model.getAttribute("agreeAllEnabled")); + assertFalse(model.containsAttribute("showTermsOfUse")); + assertFalse(model.containsAttribute("termsOfUse")); + + verify(agreementTypeConfigService, never()).getEnabledTypes(); + verify(agreementsFacade, never()).getAgreement(org.mockito.ArgumentMatchers.anyString()); + } + + @Test + @DisplayName("전체동의가 꺼져 있으면 agreeAllEnabled=false") + void applyAgreeAllMode_disabled() { + when(agreementTypeConfigService.isAgreeAllEnabled()).thenReturn(false); + + agreementFormModelSupport.applyAgreeAllMode(model); + + assertEquals(Boolean.FALSE, model.getAttribute("agreeAllEnabled")); + } +} diff --git a/src/test/java/com/eactive/apim/portal/apps/user/AccountControllerTest.java b/src/test/java/com/eactive/apim/portal/apps/user/AccountControllerTest.java index c5f6a2f..60aeb35 100644 --- a/src/test/java/com/eactive/apim/portal/apps/user/AccountControllerTest.java +++ b/src/test/java/com/eactive/apim/portal/apps/user/AccountControllerTest.java @@ -1,6 +1,7 @@ package com.eactive.apim.portal.apps.user; +import com.eactive.apim.portal.apps.agreements.service.AgreementFormModelSupport; import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade; import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties; import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService; @@ -31,7 +32,10 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -54,6 +58,9 @@ class AccountControllerTest { @Mock private AgreementsFacade agreementsFacade; + @Mock + private AgreementFormModelSupport agreementFormModelSupport; + @Mock private AuthFacade authFacade; @@ -178,4 +185,59 @@ class AccountControllerTest { .andExpect(model().attributeExists("user")); } } + + @Test + void testOrgTransferPageAppliesAgreeAllMode() throws Exception { + PortalAuthenticatedUser authenticatedUser = new PortalAuthenticatedUser(); + authenticatedUser.setId("user-1"); + authenticatedUser.setLoginId("testUser"); + authenticatedUser.setRoleCode(RoleCode.ROLE_USER); + + PortalUserDTO dto = new PortalUserDTO(); + dto.setId("user-1"); + dto.setLoginId("testUser"); + dto.setRoleCode(RoleCode.ROLE_USER); + + try (MockedStatic securityUtil = mockStatic(SecurityUtil.class)) { + securityUtil.when(SecurityUtil::getPortalAuthenticatedUser).thenReturn(authenticatedUser); + when(userFacade.findById("user-1")).thenReturn(dto); + // 동의 항목 구성은 화면 그대로 두고 '전체 동의' 허용 여부만 설정을 따른다 + doAnswer(invocation -> { + ((org.springframework.ui.Model) invocation.getArgument(0)) + .addAttribute("agreeAllEnabled", false); + return null; + }).when(agreementFormModelSupport).applyAgreeAllMode(any(org.springframework.ui.Model.class)); + + mockMvc.perform(get("/mypage/org-transfer")) + .andExpect(status().isOk()) + .andExpect(view().name("apps/mypage/orgTransfer")) + .andExpect(model().attribute("registrationType", "corporate")) + .andExpect(model().attribute("agreeAllEnabled", false)); + + verify(agreementFormModelSupport).applyAgreeAllMode(any(org.springframework.ui.Model.class)); + } + } + + @Test + void testOrgTransferPageRejectsNonPersonalUser() throws Exception { + PortalAuthenticatedUser authenticatedUser = new PortalAuthenticatedUser(); + authenticatedUser.setId("manager-1"); + authenticatedUser.setRoleCode(RoleCode.ROLE_CORP_MANAGER); + + PortalUserDTO dto = new PortalUserDTO(); + dto.setId("manager-1"); + dto.setRoleCode(RoleCode.ROLE_CORP_MANAGER); + + try (MockedStatic securityUtil = mockStatic(SecurityUtil.class)) { + securityUtil.when(SecurityUtil::getPortalAuthenticatedUser).thenReturn(authenticatedUser); + when(userFacade.findById("manager-1")).thenReturn(dto); + + mockMvc.perform(get("/mypage/org-transfer")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/mypage")); + + verify(agreementFormModelSupport, never()) + .applyAgreeAllMode(any(org.springframework.ui.Model.class)); + } + } } diff --git a/src/test/java/com/eactive/apim/portal/apps/user/OrgRegisterControllerTest.java b/src/test/java/com/eactive/apim/portal/apps/user/OrgRegisterControllerTest.java new file mode 100644 index 0000000..cfb7271 --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/apps/user/OrgRegisterControllerTest.java @@ -0,0 +1,137 @@ +package com.eactive.apim.portal.apps.user; + +import com.eactive.apim.portal.apps.agreements.service.AgreementFormModelSupport; +import com.eactive.apim.portal.apps.user.controller.OrgRegisterController; +import com.eactive.apim.portal.apps.user.dto.PortalOrgRegistrationDTO; +import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO; +import com.eactive.apim.portal.apps.user.dto.ValidationResponse; +import com.eactive.apim.portal.apps.user.facade.OrgRegisterFacade; +import com.eactive.apim.portal.config.PortalProperties; +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.mock.web.MockHttpSession; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.ui.Model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 법인 가입 화면이 약관 노출 설정을 반영하는지 검증. + * + *

정상 진입과 오류 재표시 두 경로 모두에서 {@link AgreementFormModelSupport#applyAgreements} + * 를 거쳐야 한다. 직접 {@code getAgreement(...)} 를 부르면 사용 안 함으로 꺼둔 약관이 화면에 남는다. + */ +@ExtendWith(MockitoExtension.class) +class OrgRegisterControllerTest { + + @Mock + private AgreementFormModelSupport agreementFormModelSupport; + + @Mock + private PortalProperties portalProperties; + + @Mock + private OrgRegisterFacade orgRegisterFacade; + + @InjectMocks + private OrgRegisterController orgRegisterController; + + private Model model; + + @BeforeEach + void setUp() { + model = new ExtendedModelMap(); + } + + /** applyAgreements 가 실제로 모델을 채우는 것처럼 흉내낸다. */ + private void stubApplyAgreements() { + doAnswer(invocation -> { + Model target = invocation.getArgument(0); + target.addAttribute("showTermsOfUse", true); + target.addAttribute("showNotificationConsent", false); + target.addAttribute("agreeAllEnabled", true); + return null; + }).when(agreementFormModelSupport).applyAgreements(any(Model.class)); + } + + @Test + @DisplayName("법인 가입 폼 진입 시 약관 노출 설정을 모델에 반영한다") + void getOrgAgreement_appliesAgreementSettings() { + when(portalProperties.getAuthTtl()).thenReturn(300); + stubApplyAgreements(); + + String view = orgRegisterController.getOrgAgreement(model); + + assertEquals("apps/register/orgUserRegister", view); + assertEquals("corporate", model.getAttribute("registrationType")); + assertEquals(300, model.getAttribute("authTtl")); + + verify(agreementFormModelSupport).applyAgreements(model); + assertEquals(Boolean.TRUE, model.getAttribute("showTermsOfUse")); + assertEquals(Boolean.FALSE, model.getAttribute("showNotificationConsent")); + } + + @Test + @DisplayName("가입 실패로 폼을 다시 그릴 때도 약관 노출 설정을 반영한다") + void registerOrg_invalidResponseReappliesAgreementSettings() { + PortalOrgRegistrationDTO orgDTO = new PortalOrgRegistrationDTO(); + orgDTO.setLoginId("corp@example.com"); + + stubApplyAgreements(); + when(orgRegisterFacade.registerNewOrgUser(any(PortalOrgRegistrationDTO.class), + any(UserAgreementDTO.class), anyBoolean())) + .thenReturn(new ValidationResponse(false, "이미 가입된 이메일입니다.")); + + String view = orgRegisterController.registerOrg( + "new", orgDTO, new UserAgreementDTO(), new MockHttpSession(), model); + + assertEquals("apps/register/orgUserRegister", view); + assertEquals("이미 가입된 이메일입니다.", model.getAttribute("error")); + assertEquals("corporate", model.getAttribute("registrationType")); + + verify(agreementFormModelSupport).applyAgreements(model); + assertEquals(Boolean.TRUE, model.getAttribute("showTermsOfUse")); + } + + @Test + @DisplayName("예외가 나도 폼을 다시 그리며 약관 노출 설정을 반영한다") + void registerOrg_exceptionReappliesAgreementSettings() { + PortalOrgRegistrationDTO orgDTO = new PortalOrgRegistrationDTO(); + orgDTO.setLoginId("corp@example.com"); + + stubApplyAgreements(); + when(orgRegisterFacade.registerNewOrgUser(any(PortalOrgRegistrationDTO.class), + any(UserAgreementDTO.class), anyBoolean())) + .thenThrow(new RuntimeException("DB down")); + + String view = orgRegisterController.registerOrg( + "new", orgDTO, new UserAgreementDTO(), new MockHttpSession(), model); + + assertEquals("apps/register/orgUserRegister", view); + assertEquals("처리 중 오류가 발생했습니다: DB down", model.getAttribute("error")); + + verify(agreementFormModelSupport, times(1)).applyAgreements(model); + } + + @Test + @DisplayName("알 수 없는 시나리오는 약관 재적용 없이 오류만 표시한다") + void registerOrg_unknownScenario() { + String view = orgRegisterController.registerOrg( + "bogus", new PortalOrgRegistrationDTO(), new UserAgreementDTO(), + new MockHttpSession(), model); + + assertEquals("apps/register/orgUserRegister", view); + assertEquals("잘못된 등록입니다.", model.getAttribute("error")); + } +} diff --git a/src/test/java/com/eactive/apim/portal/apps/user/UserManControllerTest.java b/src/test/java/com/eactive/apim/portal/apps/user/UserManControllerTest.java new file mode 100644 index 0000000..1ffc5ec --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/apps/user/UserManControllerTest.java @@ -0,0 +1,99 @@ +package com.eactive.apim.portal.apps.user; + +import com.eactive.apim.portal.agreements.entity.AgreementType; +import com.eactive.apim.portal.agreements.service.AgreementTypeConfigService; +import com.eactive.apim.portal.apps.user.controller.UserManController; +import com.eactive.apim.portal.apps.user.dto.PortalOrgDTO; +import com.eactive.apim.portal.apps.user.dto.PortalUserDTO; +import com.eactive.apim.portal.apps.user.facade.UserManFacade; +import com.eactive.apim.portal.common.user.PortalAuthenticatedUser; +import com.eactive.apim.portal.common.util.SecurityUtil; +import java.util.Collections; +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.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.ui.Model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +/** + * 사용자 목록 화면의 알림 수신 동의 노출 플래그 검증. + * + *

초대/초대취소 팝업의 체크박스는 알림 수신 동의서가 약관 페이지에 배치되어 있을 때만 + * 노출한다(동의서 링크를 안내할 수 있어야 하므로). + */ +@ExtendWith(MockitoExtension.class) +class UserManControllerTest { + + @Mock + private UserManFacade userManFacade; + + @Mock + private AgreementTypeConfigService agreementTypeConfigService; + + @InjectMocks + private UserManController userManController; + + private Model model; + + @BeforeEach + void setUp() { + model = new ExtendedModelMap(); + } + + private PortalAuthenticatedUser authenticatedUser() { + PortalAuthenticatedUser user = new PortalAuthenticatedUser(); + user.setId("manager-1"); + user.setLoginId("manager"); + return user; + } + + private String callUserList(boolean notificationDisplayed) { + Page users = new PageImpl<>(Collections.emptyList()); + PortalOrgDTO userOrg = new PortalOrgDTO(); + userOrg.setId("org-1"); + + try (MockedStatic securityUtil = mockStatic(SecurityUtil.class)) { + securityUtil.when(SecurityUtil::getUserOrg).thenReturn(userOrg); + securityUtil.when(SecurityUtil::getPortalAuthenticatedUser).thenReturn(authenticatedUser()); + + when(userManFacade.getPendingUsers(userOrg)).thenReturn(Collections.emptyList()); + when(userManFacade.getUsers(any(PortalOrgDTO.class), any(Pageable.class))).thenReturn(users); + when(agreementTypeConfigService.isDisplayed(AgreementType.NOTIFICATION_CONSENT)) + .thenReturn(notificationDisplayed); + + return userManController.userList(Pageable.unpaged(), model); + } + } + + @Test + @DisplayName("알림 수신 동의서가 약관 페이지에 배치되어 있으면 노출 플래그가 켜진다") + void userList_notificationConsentAvailable() { + String view = callUserList(true); + + assertEquals("apps/users/userList", view); + assertEquals(Boolean.TRUE, model.getAttribute("notificationConsentAvailable")); + assertEquals("manager-1", model.getAttribute("currentUserId")); + } + + @Test + @DisplayName("미배치/사용안함이면 노출 플래그가 꺼진다") + void userList_notificationConsentUnavailable() { + String view = callUserList(false); + + assertEquals("apps/users/userList", view); + assertEquals(Boolean.FALSE, model.getAttribute("notificationConsentAvailable")); + } +} diff --git a/src/test/java/com/eactive/apim/portal/apps/user/UserRegisterControllerAgreementTest.java b/src/test/java/com/eactive/apim/portal/apps/user/UserRegisterControllerAgreementTest.java new file mode 100644 index 0000000..3398525 --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/apps/user/UserRegisterControllerAgreementTest.java @@ -0,0 +1,242 @@ +package com.eactive.apim.portal.apps.user; + +import com.eactive.apim.portal.apps.agreements.service.AgreementFormModelSupport; +import com.eactive.apim.portal.apps.user.controller.UserRegisterController; +import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO; +import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO; +import com.eactive.apim.portal.apps.user.dto.ValidationResponse; +import com.eactive.apim.portal.apps.user.facade.AuthFacade; +import com.eactive.apim.portal.apps.user.facade.UserRegisterFacade; +import com.eactive.apim.portal.apps.user.service.PortalUserAuthService; +import com.eactive.apim.portal.apps.user.validator.AgreementValidator; +import com.eactive.apim.portal.common.user.PortalAuthenticatedUser; +import com.eactive.apim.portal.common.util.SecurityUtil; +import com.eactive.apim.portal.config.PortalProperties; +import com.eactive.apim.portal.invitation.entity.UserInvitation; +import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus; +import com.eactive.apim.portal.invitation.repository.UserInvitationRepository; +import com.eactive.apim.portal.portalorg.entity.PortalOrg; +import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository; +import com.eactive.apim.portal.portaluser.repository.PortalUserRepository; +import com.eactive.apim.portal.apps.user.service.PortalUserService; +import java.util.Optional; +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.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.ui.Model; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.BindingResult; +import org.springframework.web.servlet.mvc.support.RedirectAttributesModelMap; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 개인 가입 / 법인 전환 화면이 약관 노출 설정을 반영하는지 검증. + * + *

약관 항목을 그리는 경로가 네 군데(최초 진입, 전환 동의 화면, 전환 동의 실패 재표시, + * 가입 실패 재표시)라 어느 하나라도 {@code applyAgreements} 를 빠뜨리면 + * 사용 안 함으로 꺼둔 약관이 그 화면에서만 되살아난다. + */ +@ExtendWith(MockitoExtension.class) +class UserRegisterControllerAgreementTest { + + @Mock + private PortalUserService portalUserService; + + @Mock + private UserRegisterFacade userRegisterFacade; + + @Mock + private AuthFacade authFacade; + + @Mock + private PortalUserRepository portalUserRepository; + + @Mock + private PortalOrgRepository portalOrgRepository; + + @Mock + private AgreementFormModelSupport agreementFormModelSupport; + + @Mock + private PortalProperties portalProperties; + + @Mock + private UserInvitationRepository userInvitationRepository; + + @Mock + private AgreementValidator agreementValidator; + + @Mock + private PortalUserAuthService portalUserAuthService; + + @InjectMocks + private UserRegisterController userRegisterController; + + private Model model; + + @BeforeEach + void setUp() { + model = new ExtendedModelMap(); + } + + /** applyAgreements 가 실제로 모델을 채우는 것처럼 흉내낸다. */ + private void stubApplyAgreements() { + doAnswer(invocation -> { + Model target = invocation.getArgument(0); + target.addAttribute("showTermsOfUse", true); + target.addAttribute("showNotificationConsent", false); + target.addAttribute("agreeAllEnabled", true); + return null; + }).when(agreementFormModelSupport).applyAgreements(any(Model.class)); + } + + private void assertAgreementSettingsApplied() { + verify(agreementFormModelSupport).applyAgreements(model); + assertEquals(Boolean.TRUE, model.getAttribute("showTermsOfUse")); + assertEquals(Boolean.FALSE, model.getAttribute("showNotificationConsent")); + } + + private PortalAuthenticatedUser authenticatedUser() { + PortalAuthenticatedUser user = new PortalAuthenticatedUser(); + user.setId("user-1"); + user.setLoginId("testUser"); + user.setMobileNumber("01012345678"); + return user; + } + + private UserInvitation pendingInvitation() { + UserInvitation invitation = new UserInvitation(); + invitation.setOrgId("org-1"); + invitation.setToken("TOKEN123"); + invitation.setStatus(InvitationStatus.PENDING); + invitation.setInvitationMobile("010-1234-5678"); + return invitation; + } + + private PortalOrg org() { + PortalOrg org = new PortalOrg(); + org.setOrgName("제주테스트"); + return org; + } + + @Test + @DisplayName("개인 가입 폼 진입 시 약관 노출 설정을 반영한다") + void getUserAgreement_appliesAgreementSettings() { + when(portalProperties.getAuthTtl()).thenReturn(300); + stubApplyAgreements(); + + String view = userRegisterController.getUserAgreement(null, new MockHttpSession(), model); + + assertEquals("apps/register/userRegister", view); + assertEquals("personal", model.getAttribute("registrationType")); + assertEquals(false, model.getAttribute("isInvited")); + assertAgreementSettingsApplied(); + } + + @Test + @DisplayName("가입 실패로 폼을 다시 그릴 때도 약관 노출 설정을 반영한다") + void registerUser_bindingErrorReappliesAgreementSettings() { + stubApplyAgreements(); + + PortalUserRegistrationDTO dto = new PortalUserRegistrationDTO(); + BindingResult bindingResult = new BeanPropertyBindingResult(dto, "portalUser"); + bindingResult.rejectValue("loginId", "invalid", "형식이 올바르지 않습니다."); + + String view = userRegisterController.registerUser( + new UserAgreementDTO(), dto, bindingResult, + new MockHttpSession(), new MockHttpServletRequest(), + new RedirectAttributesModelMap(), model); + + assertEquals("apps/register/userRegister", view); + assertEquals("입력값을 확인해주세요.", model.getAttribute("error")); + assertEquals("personal", model.getAttribute("registrationType")); + assertAgreementSettingsApplied(); + } + + @Test + @DisplayName("법인 전환 동의 화면 진입 시 약관 노출 설정을 반영한다") + void showDecisionProcessPage_appliesAgreementSettings() { + stubApplyAgreements(); + + try (MockedStatic securityUtil = mockStatic(SecurityUtil.class)) { + securityUtil.when(SecurityUtil::getPortalAuthenticatedUser).thenReturn(authenticatedUser()); + + when(userInvitationRepository.findFirstByInvitationMobileAndStatus( + "010-1234-5678", InvitationStatus.PENDING)) + .thenReturn(Optional.of(pendingInvitation())); + when(portalOrgRepository.findById("org-1")).thenReturn(Optional.of(org())); + + String view = userRegisterController.showDecisionProcessPage(new MockHttpSession(), model); + + assertEquals("apps/register/userDecisionProcess", view); + assertEquals("제주테스트", model.getAttribute("orgName")); + assertEquals("corporate", model.getAttribute("registrationType")); + assertAgreementSettingsApplied(); + } + } + + @Test + @DisplayName("법인 전환 동의 실패로 화면을 다시 그릴 때도 약관 노출 설정을 반영한다") + void processInvitation_bindingErrorReappliesAgreementSettings() { + stubApplyAgreements(); + + UserAgreementDTO agreement = new UserAgreementDTO(); + BindingResult bindingResult = new BeanPropertyBindingResult(agreement, "agreement"); + bindingResult.reject("required", "필수 약관에 동의해야 합니다."); + + try (MockedStatic securityUtil = mockStatic(SecurityUtil.class)) { + securityUtil.when(SecurityUtil::getPortalAuthenticatedUser).thenReturn(authenticatedUser()); + + when(userInvitationRepository.findFirstByInvitationMobileAndStatus( + "010-1234-5678", InvitationStatus.PENDING)) + .thenReturn(Optional.of(pendingInvitation())); + when(portalOrgRepository.findById("org-1")).thenReturn(Optional.of(org())); + + String view = userRegisterController.processInvitation( + agreement, "accept", bindingResult, "TOKEN123", + new RedirectAttributesModelMap(), new MockHttpSession(), model); + + assertEquals("apps/register/userDecisionProcess", view); + assertEquals("corporate", model.getAttribute("registrationType")); + assertAgreementSettingsApplied(); + } + } + + @Test + @DisplayName("가입 응답이 실패면 약관 노출 설정을 반영해 폼을 다시 그린다") + void registerUser_invalidResponseReappliesAgreementSettings() { + stubApplyAgreements(); + + PortalUserRegistrationDTO dto = new PortalUserRegistrationDTO(); + dto.setLoginId("user@example.com"); + BindingResult bindingResult = new BeanPropertyBindingResult(dto, "portalUser"); + + when(userRegisterFacade.registerNewUser(any(UserAgreementDTO.class), + any(PortalUserRegistrationDTO.class), any(BindingResult.class), + any(), any(Model.class))) + .thenReturn(new ValidationResponse(false, "이미 가입된 이메일입니다.")); + + String view = userRegisterController.registerUser( + new UserAgreementDTO(), dto, bindingResult, + new MockHttpSession(), new MockHttpServletRequest(), + new RedirectAttributesModelMap(), model); + + assertEquals("apps/register/userRegister", view); + assertEquals("이미 가입된 이메일입니다.", model.getAttribute("error")); + assertAgreementSettingsApplied(); + } +}