- 약관 컨트롤러/서비스 테스트 추가: 동의서 노출 및 동작 방식 검증
eapim-portal CI / build (push) Waiting to run
eapim-portal Test / test (push) Waiting to run

- 사용자 등록 컨트롤러 테스트: 약관 노출 항목 설정 반영 확인
- 법인 가입/사용자 관리 관련 약관 동작 테스트 추가
This commit is contained in:
Rinjae(gf63)
2026-09-15 15:33:41 +09:00
parent ddca2a8ea4
commit f7531412be
11 changed files with 1221 additions and 122 deletions
+200 -114
View File
@@ -4,7 +4,7 @@
## 프로젝트 개요 ## 프로젝트 개요
**EAPIM Portal**은 주은행을 위한 엔터프라이즈 API 포털 관리 시스템입니다. API 서비스 관리, 사용자 등록, API 키 발급, 문서화, 테스트 기능을 제공하는 웹 기반 플랫폼입니다. **EAPIM Portal**은 주은행을 위한 엔터프라이즈 API 포털 관리 시스템입니다. API 서비스 관리, 사용자 등록, API 키 발급, 문서화, 테스트 기능을 제공하는 웹 기반 플랫폼입니다.
**기술 스택:** **기술 스택:**
- Spring Boot 2.7.18 with Spring MVC and Thymeleaf - Spring Boot 2.7.18 with Spring MVC and Thymeleaf
@@ -25,50 +25,52 @@
## Git 브랜치 전략 ## Git 브랜치 전략
**저장소 정보:** **저장소 정보:**
- 기본 저장소: `ssh://git@192.168.240.178:18081/eapim/eapim-portal.git` - origin: `ssh://git@172.30.1.50:2222/djb-eapim/eapim-portal.git` (Gitea, 사내망)
- 대체 저장소: `https://git.eactive.synology.me:8090/kjb-eapim/eapim-portal.git` - 저장소가 유일한 remote다. Jenkins 파이프라인도 같은 주소를 본다.
**브랜치:** **브랜치:**
- **jenkins_with_weblogic**: Jenkins 빌드와 WebLogic 테스트를 위한 브랜치 (기본 개발 브랜치) - **master**: 기본 개발 브랜치이자 배포 기준 브랜치
- Jenkins CI/CD 파이프라인 설정 포함 - 일상적인 개발 작업(기능 개발, 버그 수정)을 여기서 직접 수행한다
- WebLogic 배포 및 테스트 환경 설정 - Jenkins 4개 파이프라인(`Jenkinsfile.*`)이 모두 `origin/master` 를 체크아웃한다
- 일상적인 개발 작업은 이 브랜치에서 수행 - `Jenkinsfile.security` 는 pollSCM 으로 master 변경을 감지해 자동 실행된다
- 기능 개발, 버그 수정, 테스트 등 모든 개발 활동의 기본 브랜치
- **master**: 완벽히 동작하는 검증된 코드 저장소 (안정 브랜치) - **feats/\***, **design\***, **develop**: 과거 작업 잔여 브랜치
- 프로덕션 배포 가능한 안정적인 코드만 포함 - 현재 활성 개발에 사용하지 않는다. 참고용으로만 남아 있다
- jenkins_with_weblogic 브랜치에서 충분히 테스트된 코드만 병합
- 직접 커밋 금지, Pull Request를 통한 병합만 허용
**브랜치 사용 가이드:** > 과거 문서에 있던 `jenkins_with_weblogic` 브랜치는 **존재하지 않는다**.
> Jenkins/WebLogic 설정은 master 의 `Jenkinsfile.deploy` 에 통합되어 있다.
**작업 흐름:**
```bash ```bash
# 개발 시작 시 jenkins_with_weblogic 브랜치에서 작업 # master 에서 바로 작업
git checkout jenkins_with_weblogic git checkout master
git pull origin master
# 기능 개발 후 커밋 # 커밋 후 푸시 → Jenkins security 파이프라인이 자동 트리거됨
git add . git add .
git commit -m "기능 설명" git commit -m "기능 설명"
git push origin master
# jenkins_with_weblogic 브랜치에 푸시
git push origin jenkins_with_weblogic
# 충분한 테스트 완료 후 master로 병합 (Pull Request 사용)
``` ```
## 빌드 명령어 ## 빌드 명령어
### 애플리케이션 실행 ### 애플리케이션 실행
- gradlew 사용 금지 - offline gradle 단독 실행 - Linux 개발 환경에서는 `gradlew` 대신 설치된 `gradle`(8.7)을 직접 쓴다. Jenkins 노드도 `/apps/opts/gradle-8.7``gradle` 을 쓴다.
- JDK/Gradle 환경 변수는 저장소 루트의 `.envrc`(direnv)로 자동 설정됨 (JAVA_HOME → Zulu JDK 8, GRADLE_USER_HOME → 격리 디렉터리) - Windows/Eclipse 환경 기동 절차는 `BOOTRUN_SETUP_GUIDE.md` 참고 (거기서는 `gradlew.bat bootRun` 사용).
- `.envrc`(direnv)는 **없다**. JAVA_HOME(JDK 8)/GRADLE_HOME 은 셸에서 직접 맞춰야 한다.
```bash ```bash
# dev 프로파일로 실행 (기본값) # bootRun 기본 프로파일은 build.gradle 의 bootProfile 기본값(local_rinjaemac)
gradle bootRun gradle bootRun
# 특정 프로파일로 실행 # 다른 프로파일로 기동 (--args 가 아니라 -PbootProfile)
gradle bootRun --args='--spring.profiles.active=stage' gradle bootRun -PbootProfile=dev
``` ```
> `bootRun` 의 프로파일은 `-PbootProfile` 로 넘긴다. `-Pprofile` 은 `ext.profile='local'`
> 이 이미 점유하고 있어 무시된다(`build.gradle` 의 주석 참고).
> Jenkins 빌드에서 쓰는 `-Pprofile=weblogic` 은 스프링 프로파일이 아니라 빌드용 플래그다.
### 빌드 ### 빌드
```bash ```bash
@@ -82,6 +84,9 @@ gradle war
# 클린 후 빌드 # 클린 후 빌드
gradle clean build gradle clean build
# SBOM(xlsx) 생성 - Jenkins 빌드 파이프라인에서 사용
gradle sbomXlsx
``` ```
### 테스트 ### 테스트
@@ -90,7 +95,7 @@ gradle clean build
# 모든 테스트 실행 # 모든 테스트 실행
gradle test gradle test
# 커버리지와 함께 테스트 실행 # 로그 상세 출력
gradle test --info gradle test --info
# 특정 테스트 클래스 실행 # 특정 테스트 클래스 실행
@@ -100,24 +105,44 @@ gradle test --tests "com.eactive.apim.portal.apps.user.AccountControllerTest"
gradle test --tests "*Controller*" 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 ```bash
# 현재 프로파일 확인 # 현재 프로파일 확인 (-Pprofile 로 넘긴 값)
gradle printProfile gradle printProfile
# 소스 세트 설정 확인 # 소스 세트 설정 확인
gradle printSourceSets gradle printSourceSets
```
# Docker 이미지 빌드 ### Docker
./build_docker.sh
`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`) 1. **elink-online-core-jpa** (`../eapim-online/elink-online-core-jpa`)
- Gateway 데이터 모델 및 JPA 엔티티 - Gateway 데이터 모델 및 JPA 엔티티
@@ -129,37 +154,53 @@ gradle printSourceSets
- 기본 리포지토리 구현, QueryDSL 지원 - 기본 리포지토리 구현, QueryDSL 지원
- 공통 예외 핸들러 및 보안 유틸리티 - 공통 예외 핸들러 및 보안 유틸리티
3. **kjb-safedb** (`../kjb-safedb`) 두 모듈은 Jenkins 파이프라인의 `Checkout dependencies` 스테이지가 같은 Gitea 서버에서
- 광주은행 SafeDB 암호화 라이브러리 자동으로 clone/reset 한다. 로컬에서도 `../eapim-online/elink-online-core-jpa`
- 데이터베이스 레벨 민감 데이터 암호화 `../elink-portal-common` 이 없으면 컴파일 자체가 되지 않는다.
- 암호화 컬럼(사용자 비밀번호, API 키 등)에 필수
**kjb-safedb (SafeDB 암호화 라이브러리)는 현재 빌드에서 빠져 있다.**
`build.gradle``implementation project(':kjb-safedb')` 가 주석 처리되어 있고
`settings.gradle` 에도 등록되어 있지 않으며 `../kjb-safedb` 디렉터리도 없다.
다시 붙일 때는 세 곳을 모두 되살려야 한다.
### 패키지 구조 ### 패키지 구조
코드는 기술 계층이 아닌 **기능 모듈**(수직 분할) 방식으로 구성됩니다: 코드는 기술 계층이 아닌 **기능 모듈**(수직 분할) 방식으로 구성됩니다:
``` ```
com.eactive.apim.portal/ com.eactive.apim/
├── apps/ # 기능 모듈 ├── portal/
│ ├── agreements/ # API 약관 │ ├── PortalApplication.java
│ ├── apis/ # API 카탈로그 & 문서 │ ├── apps/ # 기능 모듈
│ ├── apiservice/ # API 서비스 그룹핑 │ ├── HealthCheckController.java
│ ├── app/ # API 키 관리 │ ├── ReadinessController.java
│ ├── approval/ # 승인 워크플로우 │ ├── agreements/ # API 약관
│ ├── auth/ # 인증 │ ├── apis/ # API 카탈로그 & 문서
│ ├── community/ # FAQ, 공지사항, Q&A, 제휴문의 │ ├── apiservice/ # API 서비스 그룹핑
│ ├── dashboard/ # 통계 & 분석 │ ├── app/ # API 키 관리
│ ├── file/ # 파일 업로드/다운로드 │ ├── approval/ # 승인 워크플로우
│ ├── login/ # 로그인/로그아웃 │ ├── auth/ # 인증
│ ├── proxy/ # API 테스트용 Forward Proxy │ ├── community/ # FAQ, 공지사항, Q&A, 제휴문의
│ ├── sample/ # 샘플 코드 생성 │ ├── dashboard/ # 대시보드
└── user/ # 사용자 관리 │ ├── file/ # 파일 업로드/다운로드
├── common/ # 공통 관심사 │ │ ├── login/ # 로그인/로그아웃
├── config/ # Spring 설정 │ │ ├── main/ # 메인 화면
└── gateway/ # Gateway DB 직접 접근 │ │ ├── sample/ # 샘플 코드 생성
── data/ # Gateway 리포지토리 │ │ ── 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) - `controller/` - Spring MVC 컨트롤러 (@Controller)
- `service/` - 비즈니스 로직 (@Service, @Transactional) - `service/` - 비즈니스 로직 (@Service, @Transactional)
@@ -183,6 +224,9 @@ com.eactive.apim.portal/
- Entities: `com.eactive.eai.data.entity.onl.*` - Entities: `com.eactive.eai.data.entity.onl.*`
- 목적: API 명세, 서비스, 메시지 - 목적: API 명세, 서비스, 메시지
두 데이터소스 모두 **dev 프로파일에서도 JNDI 로 연결한다**(`application-dev.yml`, `application-stage.yml`).
직접 JDBC URL 을 쓰는 프로파일은 없다.
설정: `config/PortalDatasourceConfiguration.java` 설정: `config/PortalDatasourceConfiguration.java`
- 각 데이터베이스별 별도 EntityManager - 각 데이터베이스별 별도 EntityManager
- **JTA/XA 미사용**. EntityManagerFactory 별 로컬 트랜잭션 (`config/PortalConfigTransaction.java`) - **JTA/XA 미사용**. EntityManagerFactory 별 로컬 트랜잭션 (`config/PortalConfigTransaction.java`)
@@ -193,18 +237,25 @@ com.eactive.apim.portal/
환경별 설정 파일: `src/main/resources/application-{profile}.yml` 환경별 설정 파일: `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) - **stage**: 스테이징 환경 (JNDI 데이터소스, proxy to inter-dapiwas01)
- **prod**: 운영 환경 (JNDI 데이터소스, proxy to inter-apiwas00, 캐싱 활성화) - **prod**: 운영 환경 (JNDI 데이터소스, proxy to inter-apiwas00, 캐싱 활성화)
- **gf63**: 개인 개발 환경 - **local_gf63**: 개인 로컬 개발 환경
- **kjb_rinjae**: 개인 개발 환경
주요 설정: > `bootRun` 의 기본값은 `build.gradle` 에서 `local_rinjaemac` 으로 잡혀 있지만
- 세션 타임아웃: 15분 > `application-local_rinjaemac.yml` 은 저장소에 없다. 기본값 그대로 `gradle bootRun` 을
- 파일 업로드 최대: 8MB > 하면 해당 프로파일 설정 없이 뜨므로, 로컬 기동 시에는 `-PbootProfile=dev` 처럼 명시하는 편이 안전하다.
- 비밀번호 만료: 90일
- 인증 토큰 TTL: 5분 (300초) 주요 설정 (`application.yml`):
- 사용자 승인 필수: true - 세션 타임아웃: **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) 엔티티: `Approval`, `Approver`, `ApprovalStatus` (WAITING, APPROVED, REJECTED)
### API 테스트 (`apps/proxy/`, `apps/sample/`) ### API 테스트 (`apps/apis/`)
- Gateway 서버로의 Forward Proxy - API 테스터: `apps/apis/filter/ApiTesterFilter.java`
- 요청/응답 로깅이 있는 API 테스터 - Testbed 스펙 제공: `apps/apis/controller/TestbedSpecController.java`
- 여러 언어의 샘플 코드 생성 - 샘플 코드 템플릿: `application.yml``sample-code-path: classpath:/templates/sample_code`
- API 문서를 위한 Swagger UI 통합
> `apps/sample/` 은 샘플 코드 생성기가 아니라 **Thymeleaf/Security 데모 컨트롤러**
> (`ThymeleafDemoController`, `SecurityThymeleafDemoController` 등) 모음이다.
### 감사 추적 (Hibernate Envers) ### 감사 추적 (Hibernate Envers)
@@ -321,16 +374,22 @@ public interface UserMapper {
보안 설정: `config/PortalConfigSecurity.java` 보안 설정: `config/PortalConfigSecurity.java`
- 커스텀 인증 관리자: `PortalAuthenticationManager` - 커스텀 인증 관리자: `PortalAuthenticationManager`
- `changeSessionId()`를 통한 세션 고정 공격 방어 - `changeSessionId()`를 통한 세션 고정 공격 방어
- 쿠키 기반 토큰을 사용한 CSRF 방어 - CSRF: **`HttpSessionCsrfTokenRepository`(세션 저장)**, 헤더명 `X-XSRF-TOKEN` 고정
- Lucy 필터를 통한 XSS 방어 — 쿠키 기반(`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_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_CORP_USER (법인사용자) → [ROLE_API_KEY_REQUEST, ROLE_API_KEY_REQUEST_VIEW,
ROLE_ACCOUNT, ROLE_CORP_API, ROLE_DASHBOARD, ROLE_INQUIRY, ROLE_APP, ROLE_ACCOUNT]
ROLE_USER_MANAGER]
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`의 로깅 설정: 로깅은 `application.yml` `logging.file.path` 가 아니라 **logback 설정으로 제어**한다.
```yaml
logging: - 설정 파일: `src/main/resources/logback-spring.xml` (그 외 `logback-debug.xml`, `logback-local_gf63.xml`)
file: - 로그 디렉터리: `portal.logging.log-path` yml 프로퍼티 + 인스턴스명
path: /Log/App/eapim/ ```xml
level: <springProperty scope="context" name="profileLogPath" source="portal.logging.log-path"/>
com.eactive.apim.portal: DEBUG <property name="LOG_PATH" value="${profileLogPath}/${inst.Name:-devSvr00}"/>
org.hibernate.SQL: DEBUG (개발 환경만)
``` ```
- `application.yml` 기본값: `portal.logging.log-path: /logs/prod/eapim`
- 산출 파일: `${LOG_PATH}/portal.log`, `${LOG_PATH}/hibernate.log` 등 (`backup/` 에 일자 롤링)
Lombok의 `@Slf4j`와 함께 SLF4J 사용: Lombok의 `@Slf4j`와 함께 SLF4J 사용:
```java ```java
@@ -434,12 +494,10 @@ class UserControllerTest extends BaseWebTest {
} }
``` ```
### SafeDB 테스트 ### SafeDB
테스트에서는 기본적으로 SafeDB 암호화가 비활성화됩니다. 필요한 경우: SafeDB 모듈이 현재 빌드에서 빠져 있으므로(위 "멀티 모듈 구조" 참고) 테스트에서
```bash 별도로 켜고 끌 것이 없다. `-Dsafedb=` 옵션은 더 이상 동작하지 않는다.
gradle test -Dsafedb=/path/to/safedb
```
## 중요 사항 ## 중요 사항
@@ -451,15 +509,6 @@ gradle test -Dsafedb=/path/to/safedb
- 한글 메시지 프로퍼티 (`messages_ko.properties`) - 한글 메시지 프로퍼티 (`messages_ko.properties`)
- 한글 사용자 가이드 (`개발자포탈.md`) - 한글 사용자 가이드 (`개발자포탈.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`에서 적용: `PortalUserValidator`에서 적용:
@@ -472,29 +521,63 @@ API 테스트는 Gateway로의 Forward Proxy 사용:
### 세션 관리 ### 세션 관리
- 사용자당 단일 세션 강제 - 사용자당 단일 세션 강제
- 세션 타임아웃: 15분 - 세션 타임아웃: **10분** (쿠키명 `JSESSIONID_PORTAL`)
- 로그인 시 세션 고정 공격 방어 - 로그인 시 `changeSessionId()` 로 세션 고정 공격 방어
- Redis/Ehcache를 통한 세션 클러스터링 (스테이징/운영) - 세션 상태는 DB 에 보관하며 `apps/session/` (filter/entity/repository) 과
`spring/DatabaseSessionVerifier.java` 가 담당한다. **Redis 는 쓰지 않는다.**
### 파일 업로드 제한 ### 파일 업로드 제한
- 최대 파일 크기: 8MB `application.yml` 의 `portal.file` 로 제어하고 `config/PortalProperties.java` 가 바인딩한다:
- `FileService`에서 허용 확장자 설정
- 파일 저장 위치: `/Log/App/eapim/files/` ```yaml
- 업로드 시 XSS 스캐닝 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 파일 호환 서버: WAR 파일 호환 서버:
- **JEUS** (`jeus-web-dd.xml` 필요) - **WebLogic** (`src/main/resources/weblogic.xml`) — 현재 실제 배포 대상
- **WebLogic** (`weblogic.xml` 필요) - **JEUS** (`src/main/resources/jeus-web-dd.xml`)
- **Tomcat** (Spring Boot 내장) - **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 설정 필수
스테이징/운영 환경에서 JNDI 리소스 설정: dev 를 포함한 모든 환경에서 WAS 에 JNDI 리소스가 있어야 한다:
- `jdbc/dsOBP_EMS` → Portal 데이터베이스 - `jdbc/dsOBP_EMS` → Portal 데이터베이스
- `jdbc/dsOBP_AGW` → Gateway 데이터베이스 - `jdbc/dsOBP_AGW` → Gateway 데이터베이스
@@ -511,8 +594,8 @@ JAVA_OPTS="-Xmx2g -Xms1g -Dspring.profiles.active=prod"
# WAR 빌드 # WAR 빌드
gradle bootWar gradle bootWar
# Docker 이미지 빌드 # Docker 이미지 빌드 (build_docker.sh 는 없다)
./build_docker.sh docker build -t eapim-portal:latest .
# 컨테이너 실행 # 컨테이너 실행
docker run -p 30200:30200 \ docker run -p 30200:30200 \
@@ -526,13 +609,16 @@ docker run -p 30200:30200 \
다음 항목에 영향을 주는 변경 시: 다음 항목에 영향을 주는 변경 시:
- **Gateway API 명세**: `elink-online-core-jpa` 모듈 확인 - **Gateway API 명세**: `elink-online-core-jpa` 모듈 확인
- **공통 유틸리티**: `elink-portal-common` 모듈 확인 - **공통 유틸리티**: `elink-portal-common` 모듈 확인
- **암호화**: `kjb-safedb` 모듈 확인 - **암호화**: `kjb-safedb` 모듈 — 현재 빌드에서 제외됨 (위 "멀티 모듈 구조" 참고)
- **Admin 포털**: `../eapim-admin/` 관련 프로젝트 - **Admin 포털**: `../eapim-admin/` 관련 프로젝트
- **Online 포털**: `../eapim-online/` 관련 프로젝트 - **Online 포털**: `../eapim-online/` 관련 프로젝트
## 추가 자료 ## 추가 자료
- 사용자 가이드 (한글): `개발자포탈.md` - 사용자 가이드 (한글): `개발자포탈.md`
- 빌드 스크립트: `build-gf63.sh`, `deploy_portal.sh`, `deploy_portal2.sh` - 로컬 기동 가이드 (Windows/Eclipse): `BOOTRUN_SETUP_GUIDE.md`
- Docker: `Dockerfile`, `build_docker.sh` - 개발환경 준비, OHS 정적리소스 설정: `djb-docs/`
- SQL 스크립트: `../kjb-eapim-sql/` - 메뉴 관리 개발 가이드: `readme-docs/메뉴-관리-개발-가이드.md`
- SASS/프론트 빌드: `package.json`, `sass-build.sh`, `tools/forge-entry.js`
- Docker: `Dockerfile`
- CI 설정: `Jenkinsfile.*`, `sonar-project.properties`, `ci/`
+19 -3
View File
@@ -43,8 +43,9 @@ pipeline {
) )
booleanParam( booleanParam(
name: 'RUN_TESTS', name: 'RUN_TESTS',
defaultValue: false, defaultValue: true,
description: '단위 테스트를 함께 실행해 JUnit 결과를 Sonar 로 전송한다(분석 시간 증가).' description: '단위 테스트 + JaCoCo 커버리지를 Sonar 로 전송한다(분석 시간 증가). ' +
'끄면 커버리지 리포트가 없어 Sonar 가 Coverage 를 0% 로 집계하고 Quality Gate 가 실패한다.'
) )
booleanParam( booleanParam(
name: 'UPDATE_NVD', name: 'UPDATE_NVD',
@@ -166,14 +167,18 @@ pipeline {
} }
} }
// test 는 finalizedBy jacocoTestReport 로 커버리지 XML 까지 만든다(build.gradle).
// 이 스테이지를 건너뛰면 Sonar 가 커버리지 리포트를 못 받아 Coverage 0% 로 집계되고
// Quality Gate 의 "새 코드 커버리지" 조건에서 반드시 실패한다.
stage('Test') { stage('Test') {
when { expression { return params.RUN_TESTS } } when { expression { return params.RUN_TESTS } }
steps { steps {
sh 'gradle test --no-daemon' sh 'gradle test jacocoTestReport --no-daemon'
} }
post { post {
always { always {
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml' 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" JUNIT_ARG="-Dsonar.junit.reportPaths=build/test-results/test"
fi 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 결과도 함께 올린다. # SonarQube 에 Dependency-Check 플러그인이 설치돼 있으면 CVE 결과도 함께 올린다.
# 플러그인이 없으면 스캐너가 모르는 속성으로 무시한다(경고만). # 플러그인이 없으면 스캐너가 모르는 속성으로 무시한다(경고만).
DC_ARG="" DC_ARG=""
@@ -445,6 +460,7 @@ pipeline {
-Dsonar.java.libraries="$LIBS" \ -Dsonar.java.libraries="$LIBS" \
-Dsonar.java.test.libraries="$TEST_LIBS" \ -Dsonar.java.test.libraries="$TEST_LIBS" \
$JUNIT_ARG \ $JUNIT_ARG \
$COVERAGE_ARG \
$DC_ARG $DC_ARG
''' '''
} }
+17 -4
View File
@@ -26,8 +26,9 @@ pipeline {
parameters { parameters {
booleanParam( booleanParam(
name: 'RUN_TESTS', name: 'RUN_TESTS',
defaultValue: false, defaultValue: true,
description: '단위 테스트를 함께 실행해 JUnit 결과를 Sonar 로 전송한다(분석 시간 증가).' description: '단위 테스트 + JaCoCo 커버리지를 Sonar 로 전송한다(분석 시간 증가). ' +
'끄면 커버리지 리포트가 없어 Sonar 가 Coverage 를 0% 로 집계하고 Quality Gate 가 실패한다.'
) )
} }
@@ -121,14 +122,16 @@ pipeline {
} }
} }
// test 는 finalizedBy jacocoTestReport 로 커버리지 XML 까지 만든다(build.gradle).
stage('Test') { stage('Test') {
when { expression { return params.RUN_TESTS } } when { expression { return params.RUN_TESTS } }
steps { steps {
sh 'gradle test --no-daemon' sh 'gradle test jacocoTestReport --no-daemon'
} }
post { post {
always { always {
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml' 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" JUNIT_ARG="-Dsonar.junit.reportPaths=build/test-results/test"
fi 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 에서 찾는다 # Tools 에 등록된 스캐너가 없으면 PATH 에서 찾는다
if [ -n "${SONAR_SCANNER_HOME:-}" ] && [ -x "$SONAR_SCANNER_HOME/bin/sonar-scanner" ]; then if [ -n "${SONAR_SCANNER_HOME:-}" ] && [ -x "$SONAR_SCANNER_HOME/bin/sonar-scanner" ]; then
SCANNER="$SONAR_SCANNER_HOME/bin/sonar-scanner" SCANNER="$SONAR_SCANNER_HOME/bin/sonar-scanner"
@@ -215,7 +227,8 @@ pipeline {
-Dsonar.projectVersion="${BUILD_NUMBER}" \ -Dsonar.projectVersion="${BUILD_NUMBER}" \
-Dsonar.java.libraries="$LIBS" \ -Dsonar.java.libraries="$LIBS" \
-Dsonar.java.test.libraries="$TEST_LIBS" \ -Dsonar.java.test.libraries="$TEST_LIBS" \
$JUNIT_ARG $JUNIT_ARG \
$COVERAGE_ARG
''' '''
} }
} }
+28
View File
@@ -3,6 +3,7 @@ plugins {
id 'war' id 'war'
id 'eclipse' id 'eclipse'
id 'idea' id 'idea'
id 'jacoco'
id 'org.cyclonedx.bom' version '3.2.4' id 'org.cyclonedx.bom' version '3.2.4'
id 'org.springframework.boot' version '2.7.18' id 'org.springframework.boot' version '2.7.18'
id 'io.spring.dependency-management' version '1.1.3' id 'io.spring.dependency-management' version '1.1.3'
@@ -290,6 +291,33 @@ test {
} }
} }
enabled = true 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 설정이 그대로 노출되어 // 로컬 전용 설정 파일. 배포 산출물(WAR)에 실리면 Actuator/SBA 설정이 그대로 노출되어
+17
View File
@@ -27,6 +27,23 @@ sonar.java.source=8
sonar.java.binaries=build/classes/java/main sonar.java.binaries=build/classes/java/main
sonar.java.test.binaries=build/classes/java/test 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=\ sonar.exclusions=\
@@ -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}) 탭 해석 검증.
*
* <p>노출 대상과 순서는 관리 콘솔의 '약관 종류 관리' 설정을 따르고,
* 기존 북마크 호환을 위해 {@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<AgreementTabDTO> tabs() {
return (List<AgreementTabDTO>) 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<AgreementTabDTO> 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<AgreementTabDTO> 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"));
}
}
@@ -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;
/**
* 약관 동의 폼 모델 구성 검증.
*
* <p>핵심 규칙: 관리 콘솔에서 사용하지 않도록 설정한 약관 종류는
* {@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"));
}
}
@@ -1,6 +1,7 @@
package com.eactive.apim.portal.apps.user; 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.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties; import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties;
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService; 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.any;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow; 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.mockStatic;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -54,6 +58,9 @@ class AccountControllerTest {
@Mock @Mock
private AgreementsFacade agreementsFacade; private AgreementsFacade agreementsFacade;
@Mock
private AgreementFormModelSupport agreementFormModelSupport;
@Mock @Mock
private AuthFacade authFacade; private AuthFacade authFacade;
@@ -178,4 +185,59 @@ class AccountControllerTest {
.andExpect(model().attributeExists("user")); .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> 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> 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));
}
}
} }
@@ -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;
/**
* 법인 가입 화면이 약관 노출 설정을 반영하는지 검증.
*
* <p>정상 진입과 오류 재표시 두 경로 모두에서 {@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"));
}
}
@@ -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;
/**
* 사용자 목록 화면의 알림 수신 동의 노출 플래그 검증.
*
* <p>초대/초대취소 팝업의 체크박스는 알림 수신 동의서가 약관 페이지에 배치되어 있을 때만
* 노출한다(동의서 링크를 안내할 수 있어야 하므로).
*/
@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<PortalUserDTO> users = new PageImpl<>(Collections.<PortalUserDTO>emptyList());
PortalOrgDTO userOrg = new PortalOrgDTO();
userOrg.setId("org-1");
try (MockedStatic<SecurityUtil> 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"));
}
}
@@ -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;
/**
* 개인 가입 / 법인 전환 화면이 약관 노출 설정을 반영하는지 검증.
*
* <p>약관 항목을 그리는 경로가 네 군데(최초 진입, 전환 동의 화면, 전환 동의 실패 재표시,
* 가입 실패 재표시)라 어느 하나라도 {@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> 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> 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();
}
}