diff --git a/CLAUDE.md b/CLAUDE.md
index 913f081..dbd637c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,7 +4,7 @@
## 프로젝트 개요
-**EAPIM Portal**은 광주은행을 위한 엔터프라이즈 API 포털 관리 시스템입니다. API 서비스 관리, 사용자 등록, API 키 발급, 문서화, 테스트 기능을 제공하는 웹 기반 플랫폼입니다.
+**EAPIM Portal**은 제주은행을 위한 엔터프라이즈 API 포털 관리 시스템입니다. API 서비스 관리, 사용자 등록, API 키 발급, 문서화, 테스트 기능을 제공하는 웹 기반 플랫폼입니다.
**기술 스택:**
- Spring Boot 2.7.18 with Spring MVC and Thymeleaf
@@ -25,50 +25,52 @@
## Git 브랜치 전략
**저장소 정보:**
-- 기본 저장소: `ssh://git@192.168.240.178:18081/eapim/eapim-portal.git`
-- 대체 저장소: `https://git.eactive.synology.me:8090/kjb-eapim/eapim-portal.git`
+- origin: `ssh://git@172.30.1.50:2222/djb-eapim/eapim-portal.git` (Gitea, 사내망)
+- 이 저장소가 유일한 remote다. Jenkins 파이프라인도 같은 주소를 본다.
**브랜치:**
-- **jenkins_with_weblogic**: Jenkins 빌드와 WebLogic 테스트를 위한 브랜치 (기본 개발 브랜치)
- - Jenkins CI/CD 파이프라인 설정 포함
- - WebLogic 배포 및 테스트 환경 설정
- - 일상적인 개발 작업은 이 브랜치에서 수행
- - 기능 개발, 버그 수정, 테스트 등 모든 개발 활동의 기본 브랜치
+- **master**: 기본 개발 브랜치이자 배포 기준 브랜치
+ - 일상적인 개발 작업(기능 개발, 버그 수정)을 여기서 직접 수행한다
+ - Jenkins 4개 파이프라인(`Jenkinsfile.*`)이 모두 `origin/master` 를 체크아웃한다
+ - `Jenkinsfile.security` 는 pollSCM 으로 master 변경을 감지해 자동 실행된다
-- **master**: 완벽히 동작하는 검증된 코드 저장소 (안정 브랜치)
- - 프로덕션 배포 가능한 안정적인 코드만 포함
- - jenkins_with_weblogic 브랜치에서 충분히 테스트된 코드만 병합
- - 직접 커밋 금지, Pull Request를 통한 병합만 허용
+- **feats/\***, **design\***, **develop**: 과거 작업 잔여 브랜치
+ - 현재 활성 개발에 사용하지 않는다. 참고용으로만 남아 있다
-**브랜치 사용 가이드:**
+> 과거 문서에 있던 `jenkins_with_weblogic` 브랜치는 **존재하지 않는다**.
+> Jenkins/WebLogic 설정은 master 의 `Jenkinsfile.deploy` 에 통합되어 있다.
+
+**작업 흐름:**
```bash
-# 개발 시작 시 jenkins_with_weblogic 브랜치에서 작업
-git checkout jenkins_with_weblogic
+# master 에서 바로 작업
+git checkout master
+git pull origin master
-# 기능 개발 후 커밋
+# 커밋 후 푸시 → Jenkins security 파이프라인이 자동 트리거됨
git add .
git commit -m "기능 설명"
-
-# jenkins_with_weblogic 브랜치에 푸시
-git push origin jenkins_with_weblogic
-
-# 충분한 테스트 완료 후 master로 병합 (Pull Request 사용)
+git push origin master
```
## 빌드 명령어
### 애플리케이션 실행
-- gradlew 사용 금지 - offline gradle 단독 실행
-- JDK/Gradle 환경 변수는 저장소 루트의 `.envrc`(direnv)로 자동 설정됨 (JAVA_HOME → Zulu JDK 8, GRADLE_USER_HOME → 격리 디렉터리)
+- Linux 개발 환경에서는 `gradlew` 대신 설치된 `gradle`(8.7)을 직접 쓴다. Jenkins 노드도 `/apps/opts/gradle-8.7` 의 `gradle` 을 쓴다.
+- Windows/Eclipse 환경 기동 절차는 `BOOTRUN_SETUP_GUIDE.md` 참고 (거기서는 `gradlew.bat bootRun` 사용).
+- `.envrc`(direnv)는 **없다**. JAVA_HOME(JDK 8)/GRADLE_HOME 은 셸에서 직접 맞춰야 한다.
```bash
-# dev 프로파일로 실행 (기본값)
+# bootRun 기본 프로파일은 build.gradle 의 bootProfile 기본값(local_rinjaemac)
gradle bootRun
-# 특정 프로파일로 실행
-gradle bootRun --args='--spring.profiles.active=stage'
+# 다른 프로파일로 기동 (--args 가 아니라 -PbootProfile)
+gradle bootRun -PbootProfile=dev
```
+> `bootRun` 의 프로파일은 `-PbootProfile` 로 넘긴다. `-Pprofile` 은 `ext.profile='local'`
+> 이 이미 점유하고 있어 무시된다(`build.gradle` 의 주석 참고).
+> Jenkins 빌드에서 쓰는 `-Pprofile=weblogic` 은 스프링 프로파일이 아니라 빌드용 플래그다.
+
### 빌드
```bash
@@ -82,6 +84,9 @@ gradle war
# 클린 후 빌드
gradle clean build
+
+# SBOM(xlsx) 생성 - Jenkins 빌드 파이프라인에서 사용
+gradle sbomXlsx
```
### 테스트
@@ -90,7 +95,7 @@ gradle clean build
# 모든 테스트 실행
gradle test
-# 커버리지와 함께 테스트 실행
+# 로그 상세 출력
gradle test --info
# 특정 테스트 클래스 실행
@@ -100,24 +105,44 @@ gradle test --tests "com.eactive.apim.portal.apps.user.AccountControllerTest"
gradle test --tests "*Controller*"
```
+> **커버리지는 측정되지 않는다.** JaCoCo 플러그인이 `build.gradle` 에 없고
+> `sonar-project.properties` 에도 `sonar.coverage.jacoco.xmlReportPaths` 가 없다.
+> 그래서 SonarQube 의 Coverage 가 항상 0% 로 뜨고 Quality Gate 가 ERROR 가 된다
+> (Sonar 의 Zero Coverage Sensor 가 리포트 없는 라인을 전부 0 으로 채운다).
+
+### CSS/SASS 빌드
+
+```bash
+npm run sass:build # main.css (expanded)
+npm run build # main.css + main.min.css
+npm run sass:watch # 변경 감시
+./sass-build.sh # sass CLI 직접 호출 (npm 없이)
+```
+
### 개발
```bash
-# 현재 프로파일 확인
+# 현재 프로파일 확인 (-Pprofile 로 넘긴 값)
gradle printProfile
# 소스 세트 설정 확인
gradle printSourceSets
+```
-# Docker 이미지 빌드
-./build_docker.sh
+### Docker
+
+`build_docker.sh` 는 **없다**. `Dockerfile` 만 있으므로 직접 빌드한다.
+
+```bash
+gradle bootWar
+docker build -t eapim-portal:latest .
```
## 아키텍처
### 멀티 모듈 구조
-이 프로젝트는 Gradle composite build를 통해 3개의 외부 모듈에 의존합니다:
+이 프로젝트는 `settings.gradle` 의 멀티 프로젝트 구성으로 2개의 형제 디렉터리 모듈에 의존합니다:
1. **elink-online-core-jpa** (`../eapim-online/elink-online-core-jpa`)
- Gateway 데이터 모델 및 JPA 엔티티
@@ -129,37 +154,53 @@ gradle printSourceSets
- 기본 리포지토리 구현, QueryDSL 지원
- 공통 예외 핸들러 및 보안 유틸리티
-3. **kjb-safedb** (`../kjb-safedb`)
- - 광주은행 SafeDB 암호화 라이브러리
- - 데이터베이스 레벨 민감 데이터 암호화
- - 암호화 컬럼(사용자 비밀번호, API 키 등)에 필수
+두 모듈은 Jenkins 파이프라인의 `Checkout dependencies` 스테이지가 같은 Gitea 서버에서
+자동으로 clone/reset 한다. 로컬에서도 `../eapim-online/elink-online-core-jpa` 와
+`../elink-portal-common` 이 없으면 컴파일 자체가 되지 않는다.
+
+**kjb-safedb (SafeDB 암호화 라이브러리)는 현재 빌드에서 빠져 있다.**
+`build.gradle` 의 `implementation project(':kjb-safedb')` 가 주석 처리되어 있고
+`settings.gradle` 에도 등록되어 있지 않으며 `../kjb-safedb` 디렉터리도 없다.
+다시 붙일 때는 세 곳을 모두 되살려야 한다.
### 패키지 구조
코드는 기술 계층이 아닌 **기능 모듈**(수직 분할) 방식으로 구성됩니다:
```
-com.eactive.apim.portal/
-├── apps/ # 기능 모듈
-│ ├── agreements/ # API 약관
-│ ├── apis/ # API 카탈로그 & 문서
-│ ├── apiservice/ # API 서비스 그룹핑
-│ ├── app/ # API 키 관리
-│ ├── approval/ # 승인 워크플로우
-│ ├── auth/ # 인증
-│ ├── community/ # FAQ, 공지사항, Q&A, 제휴문의
-│ ├── dashboard/ # 통계 & 분석
-│ ├── file/ # 파일 업로드/다운로드
-│ ├── login/ # 로그인/로그아웃
-│ ├── proxy/ # API 테스트용 Forward Proxy
-│ ├── sample/ # 샘플 코드 생성
-│ └── user/ # 사용자 관리
-├── common/ # 공통 관심사
-├── config/ # Spring 설정
-└── gateway/ # Gateway DB 직접 접근
- └── data/ # Gateway 리포지토리
+com.eactive.apim/
+├── portal/
+│ ├── PortalApplication.java
+│ ├── apps/ # 기능 모듈
+│ │ ├── HealthCheckController.java
+│ │ ├── ReadinessController.java
+│ │ ├── agreements/ # API 약관
+│ │ ├── apis/ # API 카탈로그 & 문서
+│ │ ├── apiservice/ # API 서비스 그룹핑
+│ │ ├── app/ # API 키 관리
+│ │ ├── approval/ # 승인 워크플로우
+│ │ ├── auth/ # 인증
+│ │ ├── community/ # FAQ, 공지사항, Q&A, 제휴문의
+│ │ ├── dashboard/ # 대시보드
+│ │ ├── file/ # 파일 업로드/다운로드
+│ │ ├── login/ # 로그인/로그아웃
+│ │ ├── main/ # 메인 화면
+│ │ ├── sample/ # 샘플 코드 생성
+│ │ ├── session/ # 세션 관리 (filter/entity/repository 포함)
+│ │ ├── statistics/ # API 통계
+│ │ └── user/ # 사용자 관리
+│ ├── common/ # 공통 관심사
+│ ├── config/ # Spring 설정
+│ ├── custom/ # 사이트별 커스터마이징 설정
+│ ├── djb/ # 은행 특화 기능
+│ │ ├── apistatus/ community/ footer/ guide/ menu/ notitest/
+│ ├── spring/ # DatabaseSessionVerifier 등
+│ └── tools/ # HibernateSqlGenerator, JpaErrorLoggingAspect
+└── gateway/ # Gateway DB 직접 접근 (portal 의 하위가 아님)
```
+> `apps/proxy/` 패키지는 **더 이상 없다**. 과거 문서의 Forward Proxy 모듈 설명은 무효다.
+
각 기능 모듈은 일반적으로 다음을 포함합니다:
- `controller/` - Spring MVC 컨트롤러 (@Controller)
- `service/` - 비즈니스 로직 (@Service, @Transactional)
@@ -183,6 +224,9 @@ com.eactive.apim.portal/
- Entities: `com.eactive.eai.data.entity.onl.*`
- 목적: API 명세, 서비스, 메시지
+두 데이터소스 모두 **dev 프로파일에서도 JNDI 로 연결한다**(`application-dev.yml`, `application-stage.yml`).
+직접 JDBC URL 을 쓰는 프로파일은 없다.
+
설정: `config/PortalDatasourceConfiguration.java`
- 각 데이터베이스별 별도 EntityManager
- **JTA/XA 미사용**. EntityManagerFactory 별 로컬 트랜잭션 (`config/PortalConfigTransaction.java`)
@@ -193,18 +237,25 @@ com.eactive.apim.portal/
환경별 설정 파일: `src/main/resources/application-{profile}.yml`
-- **dev**: 개발 환경 (Oracle at 192.168.240.177:1599, DevTools 활성화, SQL 로깅)
+저장소에 실제로 있는 프로파일은 4개다:
+
+- **dev**: 개발 환경 (DevTools 활성화, SQL 로깅)
- **stage**: 스테이징 환경 (JNDI 데이터소스, proxy to inter-dapiwas01)
- **prod**: 운영 환경 (JNDI 데이터소스, proxy to inter-apiwas00, 캐싱 활성화)
-- **gf63**: 개인 개발 환경
-- **kjb_rinjae**: 개인 개발 환경
+- **local_gf63**: 개인 로컬 개발 환경
-주요 설정:
-- 세션 타임아웃: 15분
-- 파일 업로드 최대: 8MB
-- 비밀번호 만료: 90일
-- 인증 토큰 TTL: 5분 (300초)
-- 사용자 승인 필수: true
+> `bootRun` 의 기본값은 `build.gradle` 에서 `local_rinjaemac` 으로 잡혀 있지만
+> `application-local_rinjaemac.yml` 은 저장소에 없다. 기본값 그대로 `gradle bootRun` 을
+> 하면 해당 프로파일 설정 없이 뜨므로, 로컬 기동 시에는 `-PbootProfile=dev` 처럼 명시하는 편이 안전하다.
+
+주요 설정 (`application.yml`):
+- 세션 타임아웃: **10분** (`server.servlet.session.timeout: 10m`, WebLogic 은 `weblogic.xml` 의 `timeout-secs 600`)
+- 세션 쿠키명: `JSESSIONID_PORTAL`
+- 파일 업로드 최대: **10MB** (`portal.file.max-size`)
+- 비밀번호 만료: 90일 (`portal.password-expiration-days`)
+- 인증 토큰 TTL: 5분 (`portal.auth-ttl: 300`), 재발송 제한 30초
+- 사용자 승인 필수: `portal.user-approval: true`
+- 내부 사용자 판별 도메인: `portal.internal-user.email-domains`
## 주요 기능 및 비즈니스 로직
@@ -237,12 +288,14 @@ API 키는 승인 워크플로우와 함께 관리됩니다:
엔티티: `Approval`, `Approver`, `ApprovalStatus` (WAITING, APPROVED, REJECTED)
-### API 테스트 (`apps/proxy/`, `apps/sample/`)
+### API 테스트 (`apps/apis/`)
-- Gateway 서버로의 Forward Proxy
-- 요청/응답 로깅이 있는 API 테스터
-- 여러 언어의 샘플 코드 생성
-- API 문서를 위한 Swagger UI 통합
+- API 테스터: `apps/apis/filter/ApiTesterFilter.java`
+- Testbed 스펙 제공: `apps/apis/controller/TestbedSpecController.java`
+- 샘플 코드 템플릿: `application.yml` 의 `sample-code-path: classpath:/templates/sample_code`
+
+> `apps/sample/` 은 샘플 코드 생성기가 아니라 **Thymeleaf/Security 데모 컨트롤러**
+> (`ThymeleafDemoController`, `SecurityThymeleafDemoController` 등) 모음이다.
### 감사 추적 (Hibernate Envers)
@@ -321,16 +374,22 @@ public interface UserMapper {
보안 설정: `config/PortalConfigSecurity.java`
- 커스텀 인증 관리자: `PortalAuthenticationManager`
- `changeSessionId()`를 통한 세션 고정 공격 방어
-- 쿠키 기반 토큰을 사용한 CSRF 방어
-- Lucy 필터를 통한 XSS 방어
+- CSRF: **`HttpSessionCsrfTokenRepository`(세션 저장)**, 헤더명 `X-XSRF-TOKEN` 고정
+ — 쿠키 기반(`CookieCsrfTokenRepository`)이 아니다. 토큰 수명은 세션 타임아웃과 같다
+- XSS: Naver Lucy `XssEscapeServletFilter`
+- 비밀번호 전송암호화(RSA-OAEP + AES-GCM): `common/security/passwordcrypto/`
+ — 필터 순서상 MultipartFilter 이후, Lucy XSS 필터 이전에 복호화된다
-역할 계층:
+역할 계층 (정의: `src/main/resources/roles.yml`, 부팅 시 `PTL_ROLE` / `PTL_ROLE_AUTHORITY` 에 미러 적재):
```
-ROLE_USER → [ROLE_INQUIRY, ROLE_ACCOUNT]
-ROLE_CORP_USER → [ROLE_INQUIRY, ROLE_APP, ROLE_ACCOUNT]
-ROLE_CORP_MANAGER → [ROLE_API_KEY_REQUEST, ROLE_INQUIRY, ROLE_APP,
- ROLE_ACCOUNT, ROLE_CORP_API, ROLE_DASHBOARD,
- ROLE_USER_MANAGER]
+ROLE_USER (개인사용자) → [ROLE_INQUIRY, ROLE_ACCOUNT]
+
+ROLE_CORP_USER (법인사용자) → [ROLE_API_KEY_REQUEST, ROLE_API_KEY_REQUEST_VIEW,
+ ROLE_INQUIRY, ROLE_APP, ROLE_ACCOUNT]
+
+ROLE_CORP_MANAGER (법인관리자) → [ROLE_API_KEY_REQUEST, ROLE_API_KEY_REQUEST_VIEW,
+ ROLE_WEBHOOK, ROLE_INQUIRY, ROLE_APP, ROLE_ACCOUNT,
+ ROLE_CORP_API, ROLE_DASHBOARD, ROLE_USER_MANAGER]
```
### 데이터베이스 쿼리
@@ -377,15 +436,16 @@ return queryFactory.selectFrom(user)
### 로깅
-`application.yml`의 로깅 설정:
-```yaml
-logging:
- file:
- path: /Log/App/eapim/
- level:
- com.eactive.apim.portal: DEBUG
- org.hibernate.SQL: DEBUG (개발 환경만)
-```
+로깅은 `application.yml` 의 `logging.file.path` 가 아니라 **logback 설정으로 제어**한다.
+
+- 설정 파일: `src/main/resources/logback-spring.xml` (그 외 `logback-debug.xml`, `logback-local_gf63.xml`)
+- 로그 디렉터리: `portal.logging.log-path` yml 프로퍼티 + 인스턴스명
+ ```xml
+
노출 대상과 순서는 관리 콘솔의 '약관 종류 관리' 설정을 따르고,
+ * 기존 북마크 호환을 위해 {@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 핵심 규칙: 관리 콘솔에서 사용하지 않도록 설정한 약관 종류는
+ * {@code show*} 플래그가 {@code false} 이고 본문도 {@code null} 이어야 한다.
+ */
+@ExtendWith(MockitoExtension.class)
+class AgreementFormModelSupportTest {
+
+ @Mock
+ private AgreementsFacade agreementsFacade;
+
+ @Mock
+ private AgreementTypeConfigService agreementTypeConfigService;
+
+ @InjectMocks
+ private AgreementFormModelSupport agreementFormModelSupport;
+
+ private Model model;
+
+ @BeforeEach
+ void setUp() {
+ model = new ExtendedModelMap();
+ }
+
+ private AgreementsDTO agreement(String name) {
+ AgreementsDTO dto = new AgreementsDTO();
+ dto.setName(name);
+ return dto;
+ }
+
+ @Test
+ @DisplayName("세 종류 모두 사용 중이면 플래그가 켜지고 본문이 채워진다")
+ void applyAgreements_allEnabled() {
+ AgreementsDTO terms = agreement("이용약관");
+ AgreementsDTO privacy = agreement("개인정보수집동의서");
+ AgreementsDTO notification = agreement("알림 수신 동의서");
+
+ when(agreementTypeConfigService.isAgreeAllEnabled()).thenReturn(true);
+ when(agreementTypeConfigService.getEnabledTypes()).thenReturn(EnumSet.of(
+ AgreementType.TERMS_OF_USE,
+ AgreementType.PRIVACY_COLLECT,
+ AgreementType.NOTIFICATION_CONSENT));
+ when(agreementsFacade.getAgreement(AgreementType.TERMS_OF_USE.getCode())).thenReturn(terms);
+ when(agreementsFacade.getAgreement(AgreementType.PRIVACY_COLLECT.getCode())).thenReturn(privacy);
+ when(agreementsFacade.getAgreement(AgreementType.NOTIFICATION_CONSENT.getCode())).thenReturn(notification);
+
+ agreementFormModelSupport.applyAgreements(model);
+
+ assertEquals(Boolean.TRUE, model.getAttribute("agreeAllEnabled"));
+
+ assertEquals(Boolean.TRUE, model.getAttribute("showTermsOfUse"));
+ assertEquals(Boolean.TRUE, model.getAttribute("showPrivacyCollect"));
+ assertEquals(Boolean.TRUE, model.getAttribute("showNotificationConsent"));
+
+ assertSame(terms, model.getAttribute("termsOfUse"));
+ assertSame(privacy, model.getAttribute("privacyCollect"));
+ assertSame(notification, model.getAttribute("notificationConsent"));
+ }
+
+ @Test
+ @DisplayName("사용하지 않는 종류는 플래그가 꺼지고 본문을 조회조차 하지 않는다")
+ void applyAgreements_disabledTypeIsHiddenAndNotFetched() {
+ AgreementsDTO terms = agreement("이용약관");
+
+ when(agreementTypeConfigService.isAgreeAllEnabled()).thenReturn(false);
+ when(agreementTypeConfigService.getEnabledTypes())
+ .thenReturn(EnumSet.of(AgreementType.TERMS_OF_USE));
+ when(agreementsFacade.getAgreement(AgreementType.TERMS_OF_USE.getCode())).thenReturn(terms);
+
+ agreementFormModelSupport.applyAgreements(model);
+
+ assertEquals(Boolean.FALSE, model.getAttribute("agreeAllEnabled"));
+
+ assertEquals(Boolean.TRUE, model.getAttribute("showTermsOfUse"));
+ assertSame(terms, model.getAttribute("termsOfUse"));
+
+ // 꺼진 항목은 플래그 false + 본문 null 이어야 화면에서 항목 자체가 사라진다
+ assertEquals(Boolean.FALSE, model.getAttribute("showPrivacyCollect"));
+ assertEquals(Boolean.FALSE, model.getAttribute("showNotificationConsent"));
+ assertTrue(model.containsAttribute("privacyCollect"));
+ assertTrue(model.containsAttribute("notificationConsent"));
+ assertNull(model.getAttribute("privacyCollect"));
+ assertNull(model.getAttribute("notificationConsent"));
+
+ // 불필요한 조회가 나가면 안 된다
+ verify(agreementsFacade, never()).getAgreement(AgreementType.PRIVACY_COLLECT.getCode());
+ verify(agreementsFacade, never()).getAgreement(AgreementType.NOTIFICATION_CONSENT.getCode());
+ }
+
+ @Test
+ @DisplayName("사용 중인 종류가 하나도 없으면 전부 숨김 처리된다")
+ void applyAgreements_noneEnabled() {
+ when(agreementTypeConfigService.isAgreeAllEnabled()).thenReturn(false);
+ when(agreementTypeConfigService.getEnabledTypes()).thenReturn(Collections.emptySet());
+
+ agreementFormModelSupport.applyAgreements(model);
+
+ assertEquals(Boolean.FALSE, model.getAttribute("showTermsOfUse"));
+ assertEquals(Boolean.FALSE, model.getAttribute("showPrivacyCollect"));
+ assertEquals(Boolean.FALSE, model.getAttribute("showNotificationConsent"));
+ assertNull(model.getAttribute("termsOfUse"));
+ assertNull(model.getAttribute("privacyCollect"));
+ assertNull(model.getAttribute("notificationConsent"));
+
+ verify(agreementsFacade, never()).getAgreement(org.mockito.ArgumentMatchers.anyString());
+ }
+
+ @Test
+ @DisplayName("applyAgreeAllMode 는 전체동의 허용 여부만 담고 동의 항목은 건드리지 않는다")
+ void applyAgreeAllMode_onlyTouchesAgreeAllFlag() {
+ when(agreementTypeConfigService.isAgreeAllEnabled()).thenReturn(true);
+
+ agreementFormModelSupport.applyAgreeAllMode(model);
+
+ assertEquals(Boolean.TRUE, model.getAttribute("agreeAllEnabled"));
+ assertFalse(model.containsAttribute("showTermsOfUse"));
+ assertFalse(model.containsAttribute("termsOfUse"));
+
+ verify(agreementTypeConfigService, never()).getEnabledTypes();
+ verify(agreementsFacade, never()).getAgreement(org.mockito.ArgumentMatchers.anyString());
+ }
+
+ @Test
+ @DisplayName("전체동의가 꺼져 있으면 agreeAllEnabled=false")
+ void applyAgreeAllMode_disabled() {
+ when(agreementTypeConfigService.isAgreeAllEnabled()).thenReturn(false);
+
+ agreementFormModelSupport.applyAgreeAllMode(model);
+
+ assertEquals(Boolean.FALSE, model.getAttribute("agreeAllEnabled"));
+ }
+}
diff --git a/src/test/java/com/eactive/apim/portal/apps/user/AccountControllerTest.java b/src/test/java/com/eactive/apim/portal/apps/user/AccountControllerTest.java
index c5f6a2f..60aeb35 100644
--- a/src/test/java/com/eactive/apim/portal/apps/user/AccountControllerTest.java
+++ b/src/test/java/com/eactive/apim/portal/apps/user/AccountControllerTest.java
@@ -1,6 +1,7 @@
package com.eactive.apim.portal.apps.user;
+import com.eactive.apim.portal.apps.agreements.service.AgreementFormModelSupport;
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties;
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
@@ -31,7 +32,10 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -54,6 +58,9 @@ class AccountControllerTest {
@Mock
private AgreementsFacade agreementsFacade;
+ @Mock
+ private AgreementFormModelSupport agreementFormModelSupport;
+
@Mock
private AuthFacade authFacade;
@@ -178,4 +185,59 @@ class AccountControllerTest {
.andExpect(model().attributeExists("user"));
}
}
+
+ @Test
+ void testOrgTransferPageAppliesAgreeAllMode() throws Exception {
+ PortalAuthenticatedUser authenticatedUser = new PortalAuthenticatedUser();
+ authenticatedUser.setId("user-1");
+ authenticatedUser.setLoginId("testUser");
+ authenticatedUser.setRoleCode(RoleCode.ROLE_USER);
+
+ PortalUserDTO dto = new PortalUserDTO();
+ dto.setId("user-1");
+ dto.setLoginId("testUser");
+ dto.setRoleCode(RoleCode.ROLE_USER);
+
+ try (MockedStatic 정상 진입과 오류 재표시 두 경로 모두에서 {@link AgreementFormModelSupport#applyAgreements}
+ * 를 거쳐야 한다. 직접 {@code getAgreement(...)} 를 부르면 사용 안 함으로 꺼둔 약관이 화면에 남는다.
+ */
+@ExtendWith(MockitoExtension.class)
+class OrgRegisterControllerTest {
+
+ @Mock
+ private AgreementFormModelSupport agreementFormModelSupport;
+
+ @Mock
+ private PortalProperties portalProperties;
+
+ @Mock
+ private OrgRegisterFacade orgRegisterFacade;
+
+ @InjectMocks
+ private OrgRegisterController orgRegisterController;
+
+ private Model model;
+
+ @BeforeEach
+ void setUp() {
+ model = new ExtendedModelMap();
+ }
+
+ /** applyAgreements 가 실제로 모델을 채우는 것처럼 흉내낸다. */
+ private void stubApplyAgreements() {
+ doAnswer(invocation -> {
+ Model target = invocation.getArgument(0);
+ target.addAttribute("showTermsOfUse", true);
+ target.addAttribute("showNotificationConsent", false);
+ target.addAttribute("agreeAllEnabled", true);
+ return null;
+ }).when(agreementFormModelSupport).applyAgreements(any(Model.class));
+ }
+
+ @Test
+ @DisplayName("법인 가입 폼 진입 시 약관 노출 설정을 모델에 반영한다")
+ void getOrgAgreement_appliesAgreementSettings() {
+ when(portalProperties.getAuthTtl()).thenReturn(300);
+ stubApplyAgreements();
+
+ String view = orgRegisterController.getOrgAgreement(model);
+
+ assertEquals("apps/register/orgUserRegister", view);
+ assertEquals("corporate", model.getAttribute("registrationType"));
+ assertEquals(300, model.getAttribute("authTtl"));
+
+ verify(agreementFormModelSupport).applyAgreements(model);
+ assertEquals(Boolean.TRUE, model.getAttribute("showTermsOfUse"));
+ assertEquals(Boolean.FALSE, model.getAttribute("showNotificationConsent"));
+ }
+
+ @Test
+ @DisplayName("가입 실패로 폼을 다시 그릴 때도 약관 노출 설정을 반영한다")
+ void registerOrg_invalidResponseReappliesAgreementSettings() {
+ PortalOrgRegistrationDTO orgDTO = new PortalOrgRegistrationDTO();
+ orgDTO.setLoginId("corp@example.com");
+
+ stubApplyAgreements();
+ when(orgRegisterFacade.registerNewOrgUser(any(PortalOrgRegistrationDTO.class),
+ any(UserAgreementDTO.class), anyBoolean()))
+ .thenReturn(new ValidationResponse(false, "이미 가입된 이메일입니다."));
+
+ String view = orgRegisterController.registerOrg(
+ "new", orgDTO, new UserAgreementDTO(), new MockHttpSession(), model);
+
+ assertEquals("apps/register/orgUserRegister", view);
+ assertEquals("이미 가입된 이메일입니다.", model.getAttribute("error"));
+ assertEquals("corporate", model.getAttribute("registrationType"));
+
+ verify(agreementFormModelSupport).applyAgreements(model);
+ assertEquals(Boolean.TRUE, model.getAttribute("showTermsOfUse"));
+ }
+
+ @Test
+ @DisplayName("예외가 나도 폼을 다시 그리며 약관 노출 설정을 반영한다")
+ void registerOrg_exceptionReappliesAgreementSettings() {
+ PortalOrgRegistrationDTO orgDTO = new PortalOrgRegistrationDTO();
+ orgDTO.setLoginId("corp@example.com");
+
+ stubApplyAgreements();
+ when(orgRegisterFacade.registerNewOrgUser(any(PortalOrgRegistrationDTO.class),
+ any(UserAgreementDTO.class), anyBoolean()))
+ .thenThrow(new RuntimeException("DB down"));
+
+ String view = orgRegisterController.registerOrg(
+ "new", orgDTO, new UserAgreementDTO(), new MockHttpSession(), model);
+
+ assertEquals("apps/register/orgUserRegister", view);
+ assertEquals("처리 중 오류가 발생했습니다: DB down", model.getAttribute("error"));
+
+ verify(agreementFormModelSupport, times(1)).applyAgreements(model);
+ }
+
+ @Test
+ @DisplayName("알 수 없는 시나리오는 약관 재적용 없이 오류만 표시한다")
+ void registerOrg_unknownScenario() {
+ String view = orgRegisterController.registerOrg(
+ "bogus", new PortalOrgRegistrationDTO(), new UserAgreementDTO(),
+ new MockHttpSession(), model);
+
+ assertEquals("apps/register/orgUserRegister", view);
+ assertEquals("잘못된 등록입니다.", model.getAttribute("error"));
+ }
+}
diff --git a/src/test/java/com/eactive/apim/portal/apps/user/UserManControllerTest.java b/src/test/java/com/eactive/apim/portal/apps/user/UserManControllerTest.java
new file mode 100644
index 0000000..1ffc5ec
--- /dev/null
+++ b/src/test/java/com/eactive/apim/portal/apps/user/UserManControllerTest.java
@@ -0,0 +1,99 @@
+package com.eactive.apim.portal.apps.user;
+
+import com.eactive.apim.portal.agreements.entity.AgreementType;
+import com.eactive.apim.portal.agreements.service.AgreementTypeConfigService;
+import com.eactive.apim.portal.apps.user.controller.UserManController;
+import com.eactive.apim.portal.apps.user.dto.PortalOrgDTO;
+import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
+import com.eactive.apim.portal.apps.user.facade.UserManFacade;
+import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
+import com.eactive.apim.portal.common.util.SecurityUtil;
+import java.util.Collections;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.Pageable;
+import org.springframework.ui.ExtendedModelMap;
+import org.springframework.ui.Model;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+/**
+ * 사용자 목록 화면의 알림 수신 동의 노출 플래그 검증.
+ *
+ * 초대/초대취소 팝업의 체크박스는 알림 수신 동의서가 약관 페이지에 배치되어 있을 때만
+ * 노출한다(동의서 링크를 안내할 수 있어야 하므로).
+ */
+@ExtendWith(MockitoExtension.class)
+class UserManControllerTest {
+
+ @Mock
+ private UserManFacade userManFacade;
+
+ @Mock
+ private AgreementTypeConfigService agreementTypeConfigService;
+
+ @InjectMocks
+ private UserManController userManController;
+
+ private Model model;
+
+ @BeforeEach
+ void setUp() {
+ model = new ExtendedModelMap();
+ }
+
+ private PortalAuthenticatedUser authenticatedUser() {
+ PortalAuthenticatedUser user = new PortalAuthenticatedUser();
+ user.setId("manager-1");
+ user.setLoginId("manager");
+ return user;
+ }
+
+ private String callUserList(boolean notificationDisplayed) {
+ Page 약관 항목을 그리는 경로가 네 군데(최초 진입, 전환 동의 화면, 전환 동의 실패 재표시,
+ * 가입 실패 재표시)라 어느 하나라도 {@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