- 약관 컨트롤러/서비스 테스트 추가: 동의서 노출 및 동작 방식 검증
- 사용자 등록 컨트롤러 테스트: 약관 노출 항목 설정 반영 확인 - 법인 가입/사용자 관리 관련 약관 동작 테스트 추가
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 설정이 그대로 노출되어
|
||||||
|
|||||||
@@ -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=\
|
||||||
|
|||||||
+240
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+159
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+242
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user